Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove
into shard_vendors + shard_vendor_items, serves a searchable public API over
them, and ships /site/market and /site/market/vendors/:serial.
Three things the pages have to say out loud, all consequences of how the data is
gathered:
- The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be
a full cycle behind. The banner is driven by the OLDEST vendor row, not the
newest — the one stale shop is the one that wastes somebody's trip.
- A shop can be truncated. `total` exceeding `count` means the shop holds more
than the shard publishes per frame; the vendor page says "showing 250 of 3,104"
rather than presenting a partial shop as complete.
- An item may have no name. On a shard with no cliloc table the honest render is
the item id, never an invented label.
## The pre-wired visibility rules, re-checked
Part A pre-wired market.ownerName and market.location before the frame existed,
and the sibling rule it pre-wired for leaderboards (`characterName`) turned out
to be INERT because projectValue matches literal JSON keys. Both market rules
were checked against the real frame this time:
- `ownerName` is a real key. Kept.
- `location` is a real key ONLY because the frame nests it. Flat map/x/y/region
would have made the rule match nothing — the same failure, one part later. It
is nested on the wire and on the read model so one rule hides the facet, the
coordinates, the region and the house together; five flat keys would be five
rules that drift apart.
- `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a
serial that the leaderboards and guild boards resolve back to that same name
has not hidden anything.
Tests assert all three bite, on the stored read model AND on the raw frame —
the market's SSE stream is off by default but an admin can turn it on, and a rule
that worked on only one path is exactly the leak §3.6.1 records.
## Notable
- **No payload column on shard_vendors**, unlike shard_points_boards next door.
The board's top-N is a fixed-size list read whole; here the items ARE the
searchable rows, so they are normalized and nothing is left worth duplicating.
- **display_name is denormalized at ingest** (literal name preferred over the
cliloc — a player set it, so it is more specific). Resolving at query time
would put the cliloc table on the hot path and make search-by-name impossible.
Because the shard's diff sweep will not re-send an unchanged shop just because
the site learned what its items are called, a cliloc import now triggers a bulk
re-resolution — 50 ms per thousand rows, never throws.
- **updated_at is written explicitly** on every upsert. MariaDB does not fire ON
UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a
shop re-published identically is still freshly confirmed — without this the
staleness banner would age a perfectly current shop forever.
- **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not
SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise
match every listing on the shard.
- **Rate-limited** (60/min/IP), the only limited public read. Every other public
GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over
the largest shard_* table, anonymous by default.
- Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and
stopping on a short page as well as on `total`, so a concurrent sweep shrinking
the index cannot spin the walk.
## How it was tested
673 server tests pass (27 new). Client builds clean; swagger-output.json,
routes.manifest.json and routes.guards.json regenerated.
Verified full-stack against the live MariaDB and a real shard, not only units:
- 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust
sidecar, into the site — names resolving through the cliloc table ("longsword",
"katana"), real facets and regions in the filters.
- `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort
filters, paging, and the vendor detail route.
- Visibility live: fields gated to staff vanish for an anonymous caller while
shopName and price survive; audience=player 403s; enabled=0 404s; and
/shard/features correctly drops `market` so the nav hides it.
- Re-publishing a shop smaller leaves no orphan items; an identical re-publish
moves updated_at.
- The limiter fires (38x200 then 32x429 on a 70-request burst).
Not covered by an automated test: the two React pages are presentational and this
repo's client suite covers pure-logic modules only. They were driven against the
live API above, but not rendered in a DOM harness.
Co-Authored-By: Claude <noreply@anthropic.com>
1431 lines
70 KiB
JavaScript
1431 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 — a private ` +
|
|
'Ultima Online shard.\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' },
|
|
],
|
|
tags: [
|
|
{ name: 'Health', description: 'Liveness probe' },
|
|
{ name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' },
|
|
{ name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' },
|
|
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
|
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
|
{ name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' },
|
|
{ name: 'Public · Atlas', description: 'Spawn atlas / bestiary — static shard content parsed from the shard\'s own ServUO tree, independent of the sidecar' },
|
|
{ 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 · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
|
|
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
|
|
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
|
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
|
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
|
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
|
|
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
|
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
|
{ name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
|
|
{ name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' },
|
|
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
|
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
|
],
|
|
components: {
|
|
securitySchemes: {
|
|
// Web/admin session — httpOnly cookie set by the login endpoints.
|
|
cookieAuth: {
|
|
type: 'apiKey',
|
|
in: 'cookie',
|
|
name: COOKIE_NAME,
|
|
description: 'Session JWT set as an httpOnly cookie by POST /api/v1/auth/login.',
|
|
},
|
|
// Native/mobile clients — Authorization: Bearer <accessToken>.
|
|
bearerAuth: {
|
|
type: 'http',
|
|
scheme: 'bearer',
|
|
bearerFormat: 'JWT',
|
|
description: 'Access token from POST /api/v1/auth/mobile/login (or /refresh).',
|
|
},
|
|
},
|
|
schemas: {
|
|
Error: {
|
|
type: 'object',
|
|
properties: { message: { type: 'string', example: 'Not found' } },
|
|
},
|
|
ValidationError: {
|
|
type: 'object',
|
|
properties: {
|
|
errors: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
type: { type: 'string', example: 'field' },
|
|
msg: { type: 'string', example: 'Invalid value' },
|
|
path: { type: 'string', example: 'username' },
|
|
location: { type: 'string', example: 'body' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
SafeUser: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', example: 1 },
|
|
username: { type: 'string', example: 'admin' },
|
|
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
|
|
},
|
|
},
|
|
LoginRequest: {
|
|
type: 'object',
|
|
required: ['username', 'password'],
|
|
properties: {
|
|
username: { type: 'string', example: 'admin' },
|
|
password: { type: 'string', format: 'password', example: 'super-secret' },
|
|
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
|
|
},
|
|
},
|
|
RegisterRequest: {
|
|
type: 'object',
|
|
required: ['username', 'password'],
|
|
properties: {
|
|
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newplayer' },
|
|
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
|
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
|
|
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
|
|
},
|
|
},
|
|
LoginResponse: {
|
|
type: 'object',
|
|
description:
|
|
'Either a session (user) or, for 2FA accounts, a TOTP challenge to complete at /login/totp.',
|
|
properties: {
|
|
user: { $ref: '#/components/schemas/SafeUser' },
|
|
totpRequired: { type: 'boolean', example: true },
|
|
challenge: { type: 'string', description: 'Signed challenge token for the TOTP step.' },
|
|
},
|
|
},
|
|
TotpLoginRequest: {
|
|
type: 'object',
|
|
required: ['challenge'],
|
|
description: 'Second step for 2FA login. Supply either code OR recoveryCode.',
|
|
properties: {
|
|
challenge: { type: 'string', description: 'Token returned by /login when totpRequired.' },
|
|
code: { type: 'string', description: 'Current authenticator code.', example: '123456' },
|
|
recoveryCode: { type: 'string', description: 'A single-use recovery code (alternative to code).', example: 'abcde-12345' },
|
|
trustDevice: { type: 'boolean', description: 'Remember this browser so future logins skip the TOTP step (30 days).', example: false },
|
|
deviceName: { type: 'string', description: 'Optional friendly label for the Trusted Devices list.', example: 'My Laptop' },
|
|
},
|
|
},
|
|
MobileLoginRequest: {
|
|
type: 'object',
|
|
required: ['username', 'password'],
|
|
properties: {
|
|
username: { type: 'string', example: 'admin' },
|
|
password: { type: 'string', format: 'password', example: 'super-secret' },
|
|
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
|
|
recoveryCode: { type: 'string', description: 'Single-use recovery code (alternative to code).', example: 'abcde-12345' },
|
|
trustDevice: { type: 'boolean', description: 'Remember this device so future logins skip the TOTP step; the response then carries trustToken.', example: false },
|
|
device_name: { type: 'string', description: 'Optional friendly device label for Active/Trusted Devices.', example: 'Pixel 8' },
|
|
},
|
|
},
|
|
MobileTokenResponse: {
|
|
type: 'object',
|
|
properties: {
|
|
accessToken: { type: 'string', description: 'Short-lived bearer JWT.' },
|
|
refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' },
|
|
expiresIn: {
|
|
type: 'string',
|
|
description: 'Access token lifetime as a duration string (zeit/ms format, e.g. "15m").',
|
|
example: '15m',
|
|
},
|
|
user: { $ref: '#/components/schemas/SafeUser' },
|
|
trustToken: { type: 'string', nullable: true, description: 'Present only when trustDevice was requested and accepted — store securely and send as X-Trust-Token on future logins to skip TOTP.' },
|
|
trustLimitReached: { type: 'boolean', nullable: true, description: 'Present (true) when trustDevice was requested but the device cap is reached; see devices.' },
|
|
devices: { type: 'array', nullable: true, items: { $ref: '#/components/schemas/TrustedDevice' }, description: 'The existing trusted devices, when trustLimitReached is set.' },
|
|
},
|
|
},
|
|
MobileRefreshRequest: {
|
|
type: 'object',
|
|
required: ['refreshToken'],
|
|
properties: { refreshToken: { type: 'string' } },
|
|
},
|
|
MobileLogoutRequest: {
|
|
type: 'object',
|
|
properties: {
|
|
refreshToken: { type: 'string', description: 'Revoke a single session.' },
|
|
all: { type: 'boolean', description: 'Revoke every session for the user.', example: false },
|
|
},
|
|
},
|
|
MobileSsoExchangeRequest: {
|
|
type: 'object',
|
|
required: ['code', 'code_verifier'],
|
|
properties: {
|
|
code: {
|
|
type: 'string',
|
|
description: 'The single-use authorization code returned to the app callback.',
|
|
},
|
|
code_verifier: {
|
|
type: 'string',
|
|
description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.',
|
|
},
|
|
device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' },
|
|
},
|
|
},
|
|
DeviceSession: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', description: 'Session row id (pass to DELETE /auth/me/sessions/:id).' },
|
|
deviceName: { type: 'string', nullable: true, example: 'Pixel 8' },
|
|
userAgent: { type: 'string', nullable: true },
|
|
createdAt: { type: 'string', format: 'date-time' },
|
|
lastUsedAt: { type: 'string', format: 'date-time' },
|
|
expiresAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
// A device allowed to skip the TOTP step at login (MFA "Trust this device").
|
|
// Distinct from DeviceSession (a live mobile login session). Never exposes the
|
|
// trust token/hash.
|
|
TrustedDevice: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', description: 'Trusted-device id (pass to DELETE …/trusted-devices/:id).' },
|
|
platform: { type: 'string', enum: ['web', 'mobile'], example: 'web' },
|
|
deviceName: { type: 'string', nullable: true, example: 'My Laptop' },
|
|
userAgent: { type: 'string', nullable: true },
|
|
createdAt: { type: 'string', format: 'date-time' },
|
|
lastUsedAt: { type: 'string', format: 'date-time' },
|
|
expiresAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
// Result of POST /auth/me/trusted-devices. Web receives an httpOnly cookie and
|
|
// { trusted:true }; native (bearer) sessions additionally get { trustToken }.
|
|
TrustDeviceResult: {
|
|
type: 'object',
|
|
properties: {
|
|
trusted: { type: 'boolean', example: true },
|
|
trustToken: { type: 'string', nullable: true, description: 'Native clients only — store securely and send as X-Trust-Token.' },
|
|
},
|
|
},
|
|
// 409 body when the trusted-device cap is reached: the caller must revoke one
|
|
// of the listed devices before retrying.
|
|
TrustedDeviceLimit: {
|
|
type: 'object',
|
|
properties: {
|
|
error: { type: 'string', example: 'trusted_device_limit' },
|
|
devices: { type: 'array', items: { $ref: '#/components/schemas/TrustedDevice' } },
|
|
},
|
|
},
|
|
// One-time recovery (backup) codes. Returned ONLY at generation; never re-shown.
|
|
RecoveryCodes: {
|
|
type: 'object',
|
|
properties: {
|
|
recoveryCodes: { type: 'array', items: { type: 'string', example: 'abcde-12345' } },
|
|
},
|
|
},
|
|
Message: {
|
|
type: 'object',
|
|
properties: { message: { type: 'string', example: 'Logged out.' } },
|
|
},
|
|
ContactRequest: {
|
|
type: 'object',
|
|
required: ['message'],
|
|
properties: {
|
|
message: { type: 'string', maxLength: 5000, example: 'When does the shard launch?' },
|
|
email: { type: 'string', format: 'email', example: 'player@example.com' },
|
|
name: { type: 'string', maxLength: 100, example: 'Lord British' },
|
|
},
|
|
},
|
|
// Public discovery shape (GET /auth/providers) — enough for the login page
|
|
// to render a button and start the flow. Never exposes secrets or endpoints.
|
|
Provider: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string', example: 'google' },
|
|
name: { type: 'string', example: 'Google' },
|
|
icon: {
|
|
type: 'string',
|
|
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
|
|
example: 'google',
|
|
},
|
|
loginUrl: {
|
|
type: 'string',
|
|
description: 'Relative URL to begin the redirect flow.',
|
|
example: '/api/v1/auth/sso/google/start',
|
|
},
|
|
priority: { type: 'integer', description: 'Sort order (ascending).', example: 1 },
|
|
},
|
|
},
|
|
// Admin-facing provider config (GET/POST/PUT /admin/auth/providers). The
|
|
// client secret is write-only and NEVER returned — `hasSecret` reports
|
|
// whether one is stored. `builtin` marks google/discord (fixed kind/name),
|
|
// and `health` is the config-completeness check used to gate visibility.
|
|
ProviderConfig: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string', example: 'okta' },
|
|
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
|
|
name: { type: 'string', example: 'Okta' },
|
|
enabled: { type: 'boolean', example: true },
|
|
clientId: { type: 'string' },
|
|
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
|
|
authorizeUrl: { type: 'string', format: 'uri' },
|
|
tokenUrl: { type: 'string', format: 'uri' },
|
|
userinfoUrl: { type: 'string', format: 'uri' },
|
|
scopes: { type: 'string', example: 'openid email profile' },
|
|
priority: { type: 'integer', example: 10 },
|
|
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
|
|
health: { $ref: '#/components/schemas/ProviderHealth' },
|
|
},
|
|
},
|
|
ProviderHealth: {
|
|
type: 'object',
|
|
description: 'Config-completeness check that gates whether a provider is offered to end users.',
|
|
properties: {
|
|
valid: { type: 'boolean', example: true },
|
|
missing: {
|
|
type: 'array',
|
|
description: 'Names of required config fields that are still missing.',
|
|
items: { type: 'string' },
|
|
example: [],
|
|
},
|
|
},
|
|
},
|
|
ProviderCreateRequest: {
|
|
type: 'object',
|
|
required: ['id', 'kind', 'name'],
|
|
properties: {
|
|
id: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'okta' },
|
|
kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' },
|
|
name: { type: 'string', maxLength: 80, example: 'Okta' },
|
|
enabled: { type: 'boolean', example: true },
|
|
clientId: { type: 'string' },
|
|
secret: { type: 'string', format: 'password' },
|
|
authorizeUrl: { type: 'string', format: 'uri' },
|
|
tokenUrl: { type: 'string', format: 'uri' },
|
|
userinfoUrl: { type: 'string', format: 'uri' },
|
|
scopes: { type: 'string', maxLength: 500, example: 'openid email profile' },
|
|
priority: { type: 'integer', example: 10 },
|
|
},
|
|
},
|
|
Post: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', example: 12 },
|
|
category: { type: 'string', example: 'news' },
|
|
title: { type: 'string', example: 'Server maintenance this weekend' },
|
|
slug: { type: 'string', example: 'server-maintenance-this-weekend' },
|
|
excerpt: { type: 'string', nullable: true },
|
|
body: { type: 'string', nullable: true },
|
|
image_url: { type: 'string', nullable: true, example: '/uploads/1700000000-abcd.png' },
|
|
published: { type: 'boolean', example: true },
|
|
author_id: { type: 'integer', nullable: true, example: 1 },
|
|
created_at: { type: 'string', format: 'date-time' },
|
|
updated_at: { type: 'string', format: 'date-time' },
|
|
published_at: { type: 'string', format: 'date-time', nullable: true },
|
|
},
|
|
},
|
|
PostCreateRequest: {
|
|
type: 'object',
|
|
required: ['category', 'title'],
|
|
properties: {
|
|
category: { type: 'string', example: 'news' },
|
|
title: { type: 'string', maxLength: 200, example: 'Server maintenance this weekend' },
|
|
body: { type: 'string' },
|
|
image_url: { type: 'string', description: 'Required for the screenshots category.' },
|
|
published: { type: 'boolean', example: false },
|
|
},
|
|
},
|
|
PublishRequest: {
|
|
type: 'object',
|
|
required: ['published'],
|
|
properties: { published: { type: 'boolean', example: true } },
|
|
},
|
|
UploadResponse: {
|
|
type: 'object',
|
|
properties: { url: { type: 'string', example: '/uploads/1700000000-abcd.png' } },
|
|
},
|
|
WikiPage: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', example: 3 },
|
|
slug: { type: 'string', example: 'getting-started' },
|
|
title: { type: 'string', example: 'Getting Started' },
|
|
excerpt: { type: 'string' },
|
|
body: { type: 'string' },
|
|
category_id: { type: 'integer', nullable: true, example: 2 },
|
|
published: { type: 'boolean', example: true },
|
|
tags: { type: 'array', items: { type: 'string' }, example: ['newbie', 'guide'] },
|
|
created_at: { type: 'string', format: 'date-time' },
|
|
updated_at: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
WikiPageCreateRequest: {
|
|
type: 'object',
|
|
required: ['slug', 'title'],
|
|
properties: {
|
|
slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'getting-started' },
|
|
title: { type: 'string', maxLength: 200, example: 'Getting Started' },
|
|
excerpt: { type: 'string', maxLength: 400 },
|
|
body: { type: 'string' },
|
|
category_id: { type: 'integer', nullable: true },
|
|
published: { type: 'boolean', example: false },
|
|
tags: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
},
|
|
WikiCategory: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', example: 2 },
|
|
slug: { type: 'string', example: 'guides' },
|
|
title: { type: 'string', example: 'Guides' },
|
|
description: { type: 'string' },
|
|
sort_order: { type: 'integer', example: 0 },
|
|
},
|
|
},
|
|
WikiCategoryCreateRequest: {
|
|
type: 'object',
|
|
required: ['slug', 'title'],
|
|
properties: {
|
|
slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'guides' },
|
|
title: { type: 'string', maxLength: 200, example: 'Guides' },
|
|
description: { type: 'string', maxLength: 400 },
|
|
sort_order: { type: 'integer', example: 0 },
|
|
},
|
|
},
|
|
User: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', example: 1 },
|
|
username: { type: 'string', example: 'admin' },
|
|
role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'admin' },
|
|
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
|
|
email: { type: 'string', format: 'email', nullable: true },
|
|
email_verified: { type: 'boolean', example: false },
|
|
totp_enabled: { type: 'boolean', example: true },
|
|
last_login_at: { type: 'string', format: 'date-time', nullable: true },
|
|
created_at: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
UserCreateRequest: {
|
|
type: 'object',
|
|
required: ['username', 'password'],
|
|
properties: {
|
|
username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' },
|
|
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
|
role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'editor' },
|
|
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
|
|
email: { type: 'string', format: 'email', nullable: true },
|
|
},
|
|
},
|
|
// Player self-service credential changes (/api/v1/player/account/*).
|
|
ChangeUsernameRequest: {
|
|
type: 'object',
|
|
required: ['username'],
|
|
properties: {
|
|
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' },
|
|
},
|
|
},
|
|
ChangePasswordRequest: {
|
|
type: 'object',
|
|
required: ['newPassword'],
|
|
properties: {
|
|
newPassword: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
|
currentPassword: {
|
|
type: 'string',
|
|
format: 'password',
|
|
description:
|
|
'Required when the account already has a password. Omit only for an SSO-provisioned account setting its first password.',
|
|
},
|
|
},
|
|
},
|
|
PlayerAccount: {
|
|
type: 'object',
|
|
description: 'Self-service player account (GET /player/account).',
|
|
properties: {
|
|
id: { type: 'integer', example: 42 },
|
|
username: { type: 'string', example: 'newplayer' },
|
|
role: { type: 'string', enum: ['player'], example: 'player' },
|
|
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
|
|
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
|
|
totp_enabled: { type: 'boolean', example: false },
|
|
has_password: {
|
|
type: 'boolean',
|
|
description: 'False for an SSO-provisioned account that has not set a password yet.',
|
|
example: true,
|
|
},
|
|
},
|
|
},
|
|
OkFlag: {
|
|
type: 'object',
|
|
properties: { ok: { type: 'boolean', example: true } },
|
|
},
|
|
// ── Push notifications (M7) ─────────────────────────────────────────────
|
|
RegisterDeviceRequest: {
|
|
type: 'object',
|
|
required: ['endpoint'],
|
|
properties: {
|
|
endpoint: {
|
|
type: 'string',
|
|
format: 'uri',
|
|
description: 'The UnifiedPush/ntfy endpoint URL the distributor handed the app (or an FCM token). Must be an allowed HTTPS relay origin — private/loopback hosts are rejected.',
|
|
example: 'https://ntfy.example.com/UP0a1b2c3d4e5f',
|
|
},
|
|
transport: { type: 'string', enum: ['unifiedpush', 'fcm'], default: 'unifiedpush', example: 'unifiedpush' },
|
|
platform: { type: 'string', nullable: true, maxLength: 40, example: 'android' },
|
|
},
|
|
},
|
|
PushDevice: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer', example: 7 },
|
|
transport: { type: 'string', enum: ['unifiedpush', 'fcm'], example: 'unifiedpush' },
|
|
endpoint: { type: 'string', example: 'https://ntfy.example.com/UP0a1b2c3d4e5f' },
|
|
platform: { type: 'string', nullable: true, example: 'android' },
|
|
createdAt: { type: 'string', format: 'date-time' },
|
|
lastSeenAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
NotificationStream: {
|
|
type: 'object',
|
|
description: 'One subscribable push stream from the catalog.',
|
|
properties: {
|
|
id: { type: 'string', example: 'idoc.warning' },
|
|
label: { type: 'string', example: 'IDOC warnings' },
|
|
description: { type: 'string', example: 'A house falls into its final (IDOC) decay stage.' },
|
|
personal: {
|
|
type: 'boolean',
|
|
description: 'Owner-keyed — delivered only to the owning user, never fanned out publicly.',
|
|
example: false,
|
|
},
|
|
requiresLinkedAccount: {
|
|
type: 'boolean',
|
|
description: 'The stream needs a linked game account (personal streams).',
|
|
example: false,
|
|
},
|
|
},
|
|
},
|
|
NotificationStreams: {
|
|
type: 'object',
|
|
properties: {
|
|
streams: { type: 'array', items: { $ref: '#/components/schemas/NotificationStream' } },
|
|
},
|
|
},
|
|
NotificationSubscriptions: {
|
|
type: 'object',
|
|
description: 'The set of stream ids the user has opted into (used for both GET and PUT).',
|
|
properties: {
|
|
streams: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
example: ['news.post', 'idoc.warning', 'vendor.sale'],
|
|
},
|
|
},
|
|
},
|
|
// ── Moderation appeals (Phase 6c/6d) ────────────────────────────────────
|
|
Appeal: {
|
|
type: 'object',
|
|
description: 'A player-submitted moderation appeal (as returned to the player and in the staff queue).',
|
|
properties: {
|
|
id: { type: 'integer', example: 12 },
|
|
mod_action_id: { type: 'integer', example: 340 },
|
|
discord_user_id: { type: 'string', example: '216734083584917504' },
|
|
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
|
|
user_id: { type: 'integer', nullable: true, example: 42 },
|
|
status: {
|
|
type: 'string',
|
|
enum: ['pending', 'under_review', 'approved', 'denied', 'withdrawn'],
|
|
example: 'pending',
|
|
},
|
|
submitted_text: { type: 'string', example: 'I was banned by mistake — please review.' },
|
|
staff_response: { type: 'string', nullable: true, example: null },
|
|
handled_by_user_id: { type: 'integer', nullable: true, example: null },
|
|
handled_by_tag: { type: 'string', nullable: true, example: null },
|
|
reversal_status: {
|
|
type: 'string',
|
|
enum: ['none', 'done', 'failed'],
|
|
description: 'Discord-reversal outcome. done/failed only after an approval; none otherwise.',
|
|
example: 'none',
|
|
},
|
|
submitted_at: { type: 'string', format: 'date-time' },
|
|
resolved_at: { type: 'string', format: 'date-time', nullable: true, example: null },
|
|
action_target_tag: { type: 'string', nullable: true, example: 'Rogue#1234', description: 'Snapshot of the original action target tag (from mod_actions).' },
|
|
action_reason: { type: 'string', nullable: true, example: 'Spam' },
|
|
action_created_at: { type: 'string', format: 'date-time', nullable: true },
|
|
action_duration_seconds: { type: 'integer', nullable: true, example: 86400 },
|
|
submitter_username: { type: 'string', nullable: true, example: 'newplayer' },
|
|
},
|
|
},
|
|
AppealQueueItem: {
|
|
allOf: [{ $ref: '#/components/schemas/Appeal' }],
|
|
description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',
|
|
},
|
|
AppealResolveResult: {
|
|
allOf: [
|
|
{ $ref: '#/components/schemas/Appeal' },
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
reversal: {
|
|
type: 'object',
|
|
description: 'What the approval attempted against Discord.',
|
|
properties: {
|
|
attempted: { type: 'boolean', example: true },
|
|
ok: { type: 'boolean', example: true },
|
|
reversal_status: { type: 'string', enum: ['none', 'done', 'failed'], example: 'done' },
|
|
bot_status: { type: 'integer', nullable: true, example: 200, description: 'HTTP status from the bot internal call, or null when no call was made.' },
|
|
error: { type: 'string', nullable: true, example: null },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
AppealEligibleAction: {
|
|
type: 'object',
|
|
description: 'A ban/mute mod_action the caller may appeal (no active appeal outstanding).',
|
|
properties: {
|
|
id: { type: 'integer', example: 340, description: 'mod_action id — pass as mod_action_id when submitting.' },
|
|
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
|
|
target_tag: { type: 'string', nullable: true, example: 'Rogue#1234' },
|
|
reason: { type: 'string', nullable: true, example: 'Spam' },
|
|
duration_seconds: { type: 'integer', nullable: true, example: 86400 },
|
|
created_at: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
CreateAppealRequest: {
|
|
type: 'object',
|
|
required: ['mod_action_id', 'submitted_text'],
|
|
properties: {
|
|
mod_action_id: { type: 'integer', example: 340, description: 'The ban/mute mod_action to appeal (must belong to the caller).' },
|
|
submitted_text: { type: 'string', minLength: 1, maxLength: 4000, example: 'I was banned by mistake — please review.' },
|
|
},
|
|
},
|
|
ResolveAppealRequest: {
|
|
type: 'object',
|
|
required: ['status'],
|
|
properties: {
|
|
status: { type: 'string', enum: ['approved', 'denied'], example: 'approved' },
|
|
staff_response: { type: 'string', maxLength: 4000, nullable: true, example: 'Reviewed — reversing the ban.' },
|
|
},
|
|
},
|
|
TotpCodeRequest: {
|
|
type: 'object',
|
|
required: ['code'],
|
|
properties: { code: { type: 'string', example: '123456' } },
|
|
},
|
|
SiteModeRequest: {
|
|
type: 'object',
|
|
required: ['mode'],
|
|
properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' } },
|
|
},
|
|
UnbanRequest: {
|
|
type: 'object',
|
|
required: ['ip'],
|
|
properties: { ip: { type: 'string', example: '203.0.113.5' } },
|
|
},
|
|
// ── Actual mutation-response shapes ─────────────────────────────────────
|
|
// These endpoints do NOT return the generic { message } envelope; they echo
|
|
// the affected resource id/slug or a boolean flag. Documented here as-is so
|
|
// the spec matches the controllers. (The shapes are intentionally recorded
|
|
// rather than normalized — see the audit note if standardizing later.)
|
|
AccountStatus: {
|
|
type: 'object',
|
|
description: 'Self-service account security status (GET /admin/account).',
|
|
properties: {
|
|
id: { type: 'integer', example: 1 },
|
|
username: { type: 'string', example: 'admin' },
|
|
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
|
|
totp_enabled: { type: 'boolean', example: true },
|
|
},
|
|
},
|
|
TotpSetup: {
|
|
type: 'object',
|
|
description: 'Enrollment material returned by POST /account/totp/setup.',
|
|
properties: {
|
|
otpauthUrl: { type: 'string', example: `otpauth://totp/${brand.name}:admin?secret=...` },
|
|
qr: { type: 'string', description: 'QR code as a data: URL.', example: 'data:image/png;base64,iVBORw0KGgo...' },
|
|
},
|
|
},
|
|
TotpState: {
|
|
type: 'object',
|
|
description: 'Result of enabling/disabling 2FA. Enabling also returns the one-time recovery codes.',
|
|
properties: {
|
|
totp_enabled: { type: 'boolean', example: true },
|
|
recoveryCodes: {
|
|
type: 'array',
|
|
nullable: true,
|
|
description: 'Single-use recovery codes, shown ONCE on enable.',
|
|
items: { type: 'string', example: 'abcde-12345' },
|
|
},
|
|
},
|
|
},
|
|
LinkedIdentity: {
|
|
type: 'object',
|
|
properties: {
|
|
provider: { type: 'string', example: 'google' },
|
|
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
|
|
linked_at: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
SiteModeState: {
|
|
type: 'object',
|
|
description: 'Result of PUT /admin/site-mode.',
|
|
properties: {
|
|
site_mode: { type: 'string', enum: ['live', 'maintenance'], example: 'maintenance' },
|
|
changed_at: { type: 'string', format: 'date-time' },
|
|
changed_by: { type: 'string', example: 'admin' },
|
|
},
|
|
},
|
|
PublicStatus: {
|
|
type: 'object',
|
|
description: 'Public site status (GET /public/status).',
|
|
properties: {
|
|
mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' },
|
|
status_message: { type: 'string', example: '' },
|
|
version: { $ref: '#/components/schemas/PublicVersion' },
|
|
},
|
|
},
|
|
PublicVersion: {
|
|
type: 'object',
|
|
description: 'Backend identity + version (GET /public/version; also embedded in /public/status).',
|
|
properties: {
|
|
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
|
|
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
|
|
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
|
},
|
|
},
|
|
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.' },
|
|
logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo.' },
|
|
hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path.' },
|
|
favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path.' },
|
|
},
|
|
},
|
|
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' } },
|
|
},
|
|
gameAccountSignup: { type: 'boolean', example: false },
|
|
brand: { $ref: '#/components/schemas/Brand' },
|
|
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,
|
|
},
|
|
// 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 },
|
|
},
|
|
},
|
|
// ── uo-link shard data ──────────────────────────────────────────────
|
|
ShardStatus: {
|
|
type: 'object',
|
|
description: 'Public shard status (GET /public/shard/status).',
|
|
properties: {
|
|
enabled: { type: 'boolean', example: true },
|
|
status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' },
|
|
pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true },
|
|
lastEventAt: { type: 'string', format: 'date-time', nullable: true },
|
|
onlineCount: { type: 'integer', example: 12 },
|
|
economy: { $ref: '#/components/schemas/ShardEconomyPoint' },
|
|
},
|
|
},
|
|
ShardEvent: {
|
|
type: 'object',
|
|
description: 'A logged shard event.',
|
|
properties: {
|
|
id: { type: 'integer', example: 4821 },
|
|
kind: { type: 'string', example: 'vendor.sale' },
|
|
t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 },
|
|
bootId: { type: 'string', nullable: true, example: 'boot-abc123' },
|
|
payload: { type: 'object', additionalProperties: true, description: 'The full event object.' },
|
|
createdAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
ShardEconomyPoint: {
|
|
type: 'object',
|
|
nullable: true,
|
|
description: 'One gold-supply sample.',
|
|
properties: {
|
|
accounts: { type: 'integer', nullable: true, example: 240 },
|
|
gold: { type: 'integer', nullable: true, example: 1028983421 },
|
|
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
|
|
},
|
|
},
|
|
ShardOnlinePlayer: {
|
|
type: 'object',
|
|
description: 'A LINKED player online now (only accounts linked to a website user are listed).',
|
|
properties: {
|
|
serial: { type: 'string', example: '0x24C' },
|
|
name: { type: 'string', example: 'Darrow' },
|
|
map: { type: 'string', nullable: true, example: 'Trammel' },
|
|
x: { type: 'integer', nullable: true, example: 1402 },
|
|
y: { type: 'integer', nullable: true, example: 1604 },
|
|
z: { type: 'integer', nullable: true, example: 0 },
|
|
},
|
|
},
|
|
ShardVendorSale: {
|
|
type: 'object',
|
|
description: 'A player-vendor sale (visible only to the linked owner).',
|
|
properties: {
|
|
t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
|
|
itemType: { type: 'string', example: 'Longsword' },
|
|
amount: { type: 'integer', example: 1 },
|
|
price: { type: 'integer', example: 100 },
|
|
commission: { type: 'integer', nullable: true, example: 5 },
|
|
ownerAcct: { type: 'string', example: 'whitlocktech' },
|
|
},
|
|
},
|
|
ShardHouse: {
|
|
type: 'object',
|
|
description: 'A house at its current decay stage.',
|
|
properties: {
|
|
serial: { type: 'string', example: '0x4004705F' },
|
|
stage: { type: 'string', example: 'IDOC' },
|
|
map: { type: 'string', nullable: true, example: 'Trammel' },
|
|
x: { type: 'integer', nullable: true },
|
|
y: { type: 'integer', nullable: true },
|
|
z: { type: 'integer', nullable: true },
|
|
region: { type: 'string', nullable: true },
|
|
name: { type: 'string', nullable: true, example: 'An Unnamed House' },
|
|
ownerSerial: { type: 'string', nullable: true },
|
|
ownerAcct: { type: 'string', nullable: true },
|
|
builtOn: { type: 'string', format: 'date-time', nullable: true },
|
|
lastRefreshed: { type: 'string', format: 'date-time', nullable: true },
|
|
isIdoc: { type: 'boolean', example: true },
|
|
updatedAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
ShardPointsBoard: {
|
|
type: 'object',
|
|
description:
|
|
"One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side.",
|
|
properties: {
|
|
system: { type: 'string', example: 'QueensLoyalty', description: "The shard's PointsType name; the board's stable key." },
|
|
nameString: { type: 'string', nullable: true, example: "Queen's Loyalty" },
|
|
nameNumber: { type: 'integer', nullable: true, example: 1114938, description: 'Cliloc id, 0 when the name is a literal.' },
|
|
maxPoints: { type: 'integer', nullable: true, example: 30000 },
|
|
players: { type: 'integer', nullable: true, example: 842, description: 'Players actually holding points in this system.' },
|
|
showOnGump: { type: 'boolean', example: true, description: "The shard's own 'is this player-facing?' flag." },
|
|
top: {
|
|
type: 'array',
|
|
description: 'The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet.',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
rank: { type: 'integer', example: 1 },
|
|
serial: { type: 'string', example: '0x1A2B' },
|
|
name: { type: 'string', example: 'Darrow', description: 'Omitted when the leaderboards `name` field is gated above the caller.' },
|
|
points: { type: 'integer', example: 29500 },
|
|
},
|
|
},
|
|
},
|
|
t: { type: 'integer', nullable: true, description: 'Frame time, epoch ms.' },
|
|
updatedAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
ShardMarketLocation: {
|
|
type: 'object',
|
|
nullable: true,
|
|
description:
|
|
"Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller.",
|
|
properties: {
|
|
map: { type: 'string', nullable: true, example: 'Trammel' },
|
|
x: { type: 'integer', nullable: true, example: 1421 },
|
|
y: { type: 'integer', nullable: true, example: 1699 },
|
|
z: { type: 'integer', nullable: true, example: 0 },
|
|
region: { type: 'string', nullable: true, example: 'Britain' },
|
|
house: { type: 'string', nullable: true, example: "Darrow's Villa", description: "The house SIGN's name, not the house type. Null for a vendor standing outside one." },
|
|
},
|
|
},
|
|
ShardMarketListing: {
|
|
type: 'object',
|
|
description:
|
|
'One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request.',
|
|
properties: {
|
|
serial: { type: 'string', example: '0x40012ABC' },
|
|
itemId: { type: 'integer', example: 3922, description: 'ItemID (the art/graphic id).' },
|
|
hue: { type: 'integer', example: 0 },
|
|
amount: { type: 'integer', example: 1 },
|
|
price: { type: 'integer', example: 25000 },
|
|
name: { type: 'string', nullable: true, description: "The item's own literal name, set by a player. Null for most items." },
|
|
cliloc: { type: 'integer', nullable: true, example: 1023721, description: "The item's LabelNumber." },
|
|
displayName: {
|
|
type: 'string',
|
|
nullable: true,
|
|
example: 'quarter staff',
|
|
description: 'Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id.',
|
|
},
|
|
child: { type: 'boolean', example: false, description: 'Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it.' },
|
|
vendor: {
|
|
type: 'object',
|
|
properties: {
|
|
serial: { type: 'string', example: '0x40001234' },
|
|
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
|
|
ownerSerial: { type: 'string', nullable: true, example: '0x1A2B', description: 'Omitted when the market `ownerSerial` field is gated above the caller.' },
|
|
ownerName: { type: 'string', nullable: true, example: 'Darrow', description: 'Omitted when the market `ownerName` field is gated above the caller.' },
|
|
location: { $ref: '#/components/schemas/ShardMarketLocation' },
|
|
updatedAt: { type: 'string', format: 'date-time', description: 'When the shard last published this shop.' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ShardMarketPage: {
|
|
type: 'object',
|
|
description: 'A page of marketplace listings plus the unpaginated total and the staleness stamp.',
|
|
properties: {
|
|
listings: { type: 'array', items: { $ref: '#/components/schemas/ShardMarketListing' } },
|
|
total: { type: 'integer', example: 1284, description: 'Matching listings, ignoring paging.' },
|
|
limit: { type: 'integer', example: 50 },
|
|
offset: { type: 'integer', example: 0 },
|
|
vendors: { type: 'integer', example: 137, description: 'Vendors in the whole index.' },
|
|
staleAt: {
|
|
type: 'string',
|
|
format: 'date-time',
|
|
nullable: true,
|
|
description: 'The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices.',
|
|
},
|
|
},
|
|
},
|
|
ShardMarketVendor: {
|
|
type: 'object',
|
|
description: 'One player vendor and its listings.',
|
|
properties: {
|
|
serial: { type: 'string', example: '0x40001234' },
|
|
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
|
|
ownerSerial: { type: 'string', nullable: true },
|
|
ownerName: { type: 'string', nullable: true, example: 'Darrow' },
|
|
location: { $ref: '#/components/schemas/ShardMarketLocation' },
|
|
count: { type: 'integer', example: 250, description: 'Listings the shard published for this shop.' },
|
|
total: { type: 'integer', example: 3104, description: 'Listings the shop actually holds.' },
|
|
truncated: { type: 'boolean', example: true, description: '`total` exceeds `count` — the shop holds more than the shard publishes per frame.' },
|
|
updatedAt: { type: 'string', format: 'date-time' },
|
|
items: { type: 'array', items: { $ref: '#/components/schemas/ShardMarketListing' } },
|
|
},
|
|
},
|
|
ShardMarketMeta: {
|
|
type: 'object',
|
|
description: 'Marketplace size, staleness and the filter options a client needs to build its UI.',
|
|
properties: {
|
|
vendors: { type: 'integer', example: 137 },
|
|
items: { type: 'integer', example: 18422 },
|
|
staleAt: { type: 'string', format: 'date-time', nullable: true },
|
|
freshAt: { type: 'string', format: 'date-time', nullable: true },
|
|
maps: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Trammel'], description: "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." },
|
|
regions: { type: 'array', items: { type: 'string' }, example: ['Britain', 'Luna'] },
|
|
},
|
|
},
|
|
ShardFeatures: {
|
|
type: 'object',
|
|
description:
|
|
"The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403.",
|
|
properties: {
|
|
level: {
|
|
type: 'string',
|
|
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
|
example: 'anonymous',
|
|
},
|
|
features: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
example: ['status', 'activity', 'champs', 'guilds', 'governors', 'houses', 'presence'],
|
|
},
|
|
},
|
|
},
|
|
ShardFeatureVisibility: {
|
|
type: 'object',
|
|
description: 'Visibility settings for one shard feature.',
|
|
properties: {
|
|
enabled: { type: 'boolean', example: true },
|
|
audience: {
|
|
type: 'string',
|
|
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
|
description: 'Minimum rung that may reach this feature. Each rung implies the ones below it.',
|
|
example: 'anonymous',
|
|
},
|
|
stream: {
|
|
type: 'boolean',
|
|
description: "Whether this feature's event kinds fan out over SSE at all.",
|
|
example: true,
|
|
},
|
|
fieldRules: {
|
|
type: 'object',
|
|
additionalProperties: { type: 'string' },
|
|
description:
|
|
'Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here.',
|
|
example: { location: 'staff' },
|
|
},
|
|
},
|
|
},
|
|
ShardVisibilityConfig: {
|
|
type: 'object',
|
|
properties: {
|
|
ladder: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
example: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
|
},
|
|
lockedFields: { type: 'array', items: { type: 'string' }, example: ['acct', 'webId'] },
|
|
defaults: {
|
|
type: 'object',
|
|
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
|
|
},
|
|
features: {
|
|
type: 'object',
|
|
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
|
|
},
|
|
},
|
|
},
|
|
ShardVisibilityUpdate: {
|
|
type: 'object',
|
|
required: ['features'],
|
|
properties: {
|
|
features: {
|
|
type: 'object',
|
|
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
|
|
example: { market: { enabled: true, audience: 'player', stream: false, fieldRules: { ownerName: 'player' } } },
|
|
},
|
|
},
|
|
},
|
|
// ── Spawn atlas (Protocol 3.0 Part C) ────────────────────────────────
|
|
// Static shard content, parsed from the shard's own ServUO tree. Nothing
|
|
// here comes from the sidecar, so it stays populated while the shard is
|
|
// down. Facet names are whatever the shard's files declare — the examples
|
|
// below are stock ServUO, not a fixed list.
|
|
AtlasCreature: {
|
|
type: 'object',
|
|
description: 'A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route.',
|
|
properties: {
|
|
slug: { type: 'string', example: 'lizardman' },
|
|
name: { type: 'string', example: 'Lizardman' },
|
|
total: { type: 'integer', description: 'How many can be alive at once, summed across every spawner.', example: 214 },
|
|
points: { type: 'integer', description: 'How many spawners mention this creature.', example: 62 },
|
|
facets: {
|
|
type: 'object',
|
|
additionalProperties: { type: 'integer' },
|
|
description: "This creature's share per facet.",
|
|
example: { Felucca: 96, Trammel: 88, Tokuno: 30 },
|
|
},
|
|
art: { type: 'string', nullable: true, description: 'Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art.' },
|
|
places: {
|
|
type: 'array',
|
|
description: 'Where it spawns, aggregated by resolved place. The answer the atlas exists to give.',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
facet: { type: 'string', example: 'Trammel' },
|
|
label: { type: 'string', description: 'Resolved region, else nearest landmark group, else "Wilderness".', example: 'Shrines' },
|
|
spawners: { type: 'integer', example: 7 },
|
|
maxAlive: { type: 'integer', example: 21 },
|
|
},
|
|
},
|
|
},
|
|
spawners: {
|
|
type: 'array',
|
|
description: 'The individual spawners. Named separately from `points` (the count) so one key never means two things.',
|
|
items: { $ref: '#/components/schemas/AtlasSpawner' },
|
|
},
|
|
spawnersTruncated: { type: 'boolean', description: 'True when the spawner list was cut at the requested bound.', example: false },
|
|
alsoHere: {
|
|
type: 'array',
|
|
description: 'Creatures sharing a spawner with this one.',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
slug: { type: 'string', example: 'lizardman-warrior' },
|
|
name: { type: 'string', example: 'Lizardman Warrior' },
|
|
shared: { type: 'integer', example: 12 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
AtlasSpawner: {
|
|
type: 'object',
|
|
description: 'One ServUO spawner, with the place its coordinates resolved to.',
|
|
properties: {
|
|
id: { type: 'integer' },
|
|
facet: { type: 'string', example: 'Felucca' },
|
|
name: { type: 'string', nullable: true, description: "The spawner's own name in the ServUO file." },
|
|
x: { type: 'integer', example: 5411 },
|
|
y: { type: 'integer', example: 1234 },
|
|
width: { type: 'integer' },
|
|
height: { type: 'integer' },
|
|
range: { type: 'integer', description: 'Spawn radius.' },
|
|
maxCount: { type: 'integer', description: 'How many of THIS creature this spawner keeps alive.', example: 3 },
|
|
minDelay: { type: 'integer', description: 'Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag.', example: 300 },
|
|
maxDelay: { type: 'integer', example: 600 },
|
|
todStart: { type: 'integer', description: 'Meaningless unless todMode is non-zero.' },
|
|
todEnd: { type: 'integer' },
|
|
todMode: { type: 'integer' },
|
|
region: { type: 'string', nullable: true, example: 'Despise' },
|
|
landmark: { type: 'string', nullable: true, example: 'Covetous' },
|
|
label: { type: 'string', description: 'Region, else landmark group, else "Wilderness".', example: 'Despise' },
|
|
},
|
|
},
|
|
AtlasCreaturePage: {
|
|
type: 'object',
|
|
properties: {
|
|
total: { type: 'integer', description: 'Matching creatures before pagination.', example: 800 },
|
|
limit: { type: 'integer', example: 50 },
|
|
offset: { type: 'integer', example: 0 },
|
|
creatures: { type: 'array', items: { $ref: '#/components/schemas/AtlasCreature' } },
|
|
},
|
|
},
|
|
AtlasRegion: {
|
|
type: 'object',
|
|
description: 'A named region, flattened out of the shard\'s nested Regions.xml.',
|
|
properties: {
|
|
facet: { type: 'string', example: 'Felucca' },
|
|
name: { type: 'string', example: 'Despise' },
|
|
type: { type: 'string', nullable: true, description: 'ServUO region class.', example: 'DungeonRegion' },
|
|
priority: { type: 'integer', example: 50 },
|
|
parent: { type: 'string', nullable: true, example: 'Britain' },
|
|
rects: {
|
|
type: 'array',
|
|
description: 'The rectangles that placed each spawn point.',
|
|
items: { type: 'object', additionalProperties: true },
|
|
},
|
|
},
|
|
},
|
|
AtlasLandmark: {
|
|
type: 'object',
|
|
properties: {
|
|
facet: { type: 'string', example: 'Trammel' },
|
|
name: { type: 'string', example: 'Level 1' },
|
|
group: { type: 'string', nullable: true, description: 'Innermost enclosing parent — the label worth showing.', example: 'Covetous' },
|
|
x: { type: 'integer', example: 5411 },
|
|
y: { type: 'integer', example: 1234 },
|
|
z: { type: 'integer', example: 0 },
|
|
},
|
|
},
|
|
AtlasChampion: {
|
|
type: 'object',
|
|
description: 'A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that.',
|
|
properties: {
|
|
slug: { type: 'string', example: 'felucca-deceit' },
|
|
name: { type: 'string', example: 'Deceit' },
|
|
group: { type: 'string', nullable: true, description: 'Spawn group; one altar active per group.', example: 'Dungeons' },
|
|
type: { type: 'string', nullable: true, description: 'NULL when the champion is drawn at activation.', example: 'UnholyTerror' },
|
|
randomType: { type: 'boolean', example: false },
|
|
facet: { type: 'string', example: 'Felucca' },
|
|
x: { type: 'integer' },
|
|
y: { type: 'integer' },
|
|
z: { type: 'integer' },
|
|
radius: { type: 'integer', example: 60 },
|
|
label: { type: 'string', nullable: true, example: 'Deceit' },
|
|
},
|
|
},
|
|
AtlasMeta: {
|
|
type: 'object',
|
|
description: 'What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route.',
|
|
properties: {
|
|
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
|
generatedAt: { type: 'string', format: 'date-time', nullable: true },
|
|
counts: {
|
|
type: 'object',
|
|
nullable: true,
|
|
additionalProperties: true,
|
|
example: { facets: 6, points: 6455, creatures: 800, regions: 387, landmarks: 558, champions: 25, unresolvedPoints: 1086 },
|
|
},
|
|
facets: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Ilshenar', 'Malas', 'TerMur', 'Tokuno', 'Trammel'] },
|
|
},
|
|
},
|
|
AtlasStatus: {
|
|
type: 'object',
|
|
description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
|
|
properties: {
|
|
configured: { type: 'boolean', example: true },
|
|
path: { type: 'string', example: '/srv/servuo' },
|
|
treeReadable: { type: 'boolean', example: true },
|
|
drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false },
|
|
facets: { type: 'array', items: { type: 'string' } },
|
|
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
|
counts: { type: 'object', nullable: true, additionalProperties: true },
|
|
pending: {
|
|
type: 'object',
|
|
nullable: true,
|
|
description: 'A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected.',
|
|
additionalProperties: true,
|
|
},
|
|
},
|
|
},
|
|
AtlasRefreshResult: {
|
|
type: 'object',
|
|
description: 'Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500.',
|
|
properties: {
|
|
status: {
|
|
type: 'string',
|
|
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed', 'rejected', 'none'],
|
|
example: 'imported',
|
|
},
|
|
reason: { type: 'string', nullable: true },
|
|
path: { type: 'string', nullable: true },
|
|
counts: { type: 'object', nullable: true, additionalProperties: true },
|
|
addedFacets: { type: 'array', items: { type: 'string' } },
|
|
removedFacets: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
},
|
|
ClilocStatus: {
|
|
type: 'object',
|
|
description:
|
|
'Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids.',
|
|
properties: {
|
|
configured: { type: 'boolean', example: true },
|
|
path: { type: 'string', example: '/srv/uo-client' },
|
|
file: { type: 'string', nullable: true, description: 'The file actually resolved, when the path is a directory.', example: '/srv/uo-client/clilocs.tsv' },
|
|
fileReadable: { type: 'boolean', example: true },
|
|
problem: { type: 'string', nullable: true, description: 'Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file.', example: null },
|
|
code: { type: 'string', nullable: true, description: 'Machine-readable cause of `problem`.', enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED'] },
|
|
drift: { type: 'boolean', nullable: true, description: 'True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable.', example: false },
|
|
count: { type: 'integer', description: 'Entries currently loaded.', example: 67496 },
|
|
sources: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Every source found now, root-relative, base first then overlays in merge order.',
|
|
example: ['clilocs.plain', 'custom/uomysticmoon.tsv'],
|
|
},
|
|
loadedSources: {
|
|
type: 'array',
|
|
nullable: true,
|
|
description: 'What each source contributed at the last import.',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
label: { type: 'string', example: 'custom/uomysticmoon.tsv' },
|
|
kind: { type: 'string', enum: ['base', 'custom'], example: 'custom' },
|
|
entries: { type: 'integer', example: 37 },
|
|
added: { type: 'integer', description: 'Ids this source introduced.', example: 25 },
|
|
overrode: { type: 'integer', description: 'Ids it replaced from an earlier source.', example: 12 },
|
|
},
|
|
},
|
|
},
|
|
missingSources: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'Sources loaded previously and now absent. An import refuses these without `approve`.',
|
|
example: [],
|
|
},
|
|
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
|
sourceBytes: { type: 'integer', nullable: true, example: 4973525 },
|
|
},
|
|
},
|
|
ClilocRefreshResult: {
|
|
type: 'object',
|
|
description:
|
|
'Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500.',
|
|
properties: {
|
|
status: {
|
|
type: 'string',
|
|
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'],
|
|
description: '`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it.',
|
|
example: 'imported',
|
|
},
|
|
reason: { type: 'string', nullable: true },
|
|
code: {
|
|
type: 'string',
|
|
nullable: true,
|
|
description: 'Machine-readable cause. `COMPRESSED` means the client\'s own Cliloc.enu was supplied instead of a converted one.',
|
|
enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED', 'TRUNCATED', 'EMPTY', 'NOT_BUFFER'],
|
|
},
|
|
path: { type: 'string', nullable: true },
|
|
file: { type: 'string', nullable: true },
|
|
count: { type: 'integer', nullable: true, description: 'Entries stored (blank strings are dropped).', example: 67496 },
|
|
parsed: { type: 'integer', nullable: true, description: 'Entries read across every source before blanks were dropped.', example: 123527 },
|
|
blank: { type: 'integer', nullable: true, example: 55994 },
|
|
sources: {
|
|
type: 'array',
|
|
nullable: true,
|
|
description: 'Per-source breakdown: what each file contributed and how much of it overrode an earlier source.',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
label: { type: 'string' },
|
|
kind: { type: 'string', enum: ['base', 'custom'] },
|
|
entries: { type: 'integer' },
|
|
added: { type: 'integer' },
|
|
overrode: { type: 'integer' },
|
|
},
|
|
},
|
|
},
|
|
missingSources: {
|
|
type: 'array',
|
|
nullable: true,
|
|
items: { type: 'string' },
|
|
description: 'On `needsReview`: the sources that vanished. Nothing was applied.',
|
|
},
|
|
acceptedMissing: {
|
|
type: 'array',
|
|
nullable: true,
|
|
items: { type: 'string' },
|
|
description: 'On `imported` with `approve`: the vanished sources the admin accepted.',
|
|
},
|
|
},
|
|
},
|
|
ShardLinkRequest: {
|
|
type: 'object',
|
|
required: ['code'],
|
|
properties: {
|
|
code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' },
|
|
},
|
|
},
|
|
ShardLinkResult: {
|
|
type: 'object',
|
|
properties: {
|
|
linked: { type: 'boolean', example: true },
|
|
account: { type: 'string', example: 'whitlocktech' },
|
|
},
|
|
},
|
|
ShardLink: {
|
|
type: 'object',
|
|
description: 'A linked in-game account (GET /player/shard/accounts).',
|
|
properties: {
|
|
account: { type: 'string', example: 'whitlocktech' },
|
|
userId: { type: 'integer', example: 42 },
|
|
charName: { type: 'string', nullable: true, example: 'Darrow' },
|
|
linkedAt: { type: 'string', format: 'date-time' },
|
|
},
|
|
},
|
|
TownCrierRequest: {
|
|
type: 'object',
|
|
required: ['id', 'lines'],
|
|
properties: {
|
|
id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' },
|
|
lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] },
|
|
durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
swaggerAutogen(outputFile, routes, doc).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.')
|
|
})
|