The three artifacts that make this module installable and checkable, closing
phase 3's extraction. Nothing about what the module serves changes: the same 72
URLs, the same behaviour.
**The OpenAPI fragment (MODULE_API.md §2.8, §6.1a) was never built, on either
side.** The 417 `#swagger` annotations came across in slice 1 and went nowhere,
and core's /api/docs.json merged nothing — so every route this module serves was
in no spec at all, which is core's standing rule ("never ship a route that isn't
in the spec") being broken by the extraction rather than by a route.
`server/scripts/swaggerFragment.js` generates it. The prefixes are DERIVED: the
script runs the module's own `register()` against a recording api and asks
`require.cache` which file each router came from, so a mount prefix exists in one
place — `server/index.js` — and not in a table beside it. The 31 schemas moved
here from core's swagger.js, namespaced `Uo…` because core wins every key
collision in the merge; `Error` and `ValidationError` stay referenced by core's
names, since they resolve in the merged document.
**The frozen route manifest (§5.3)** is derived too, and by subtraction: CI
clones core at the ref pinned in ci/core-ref.json, generates its manifest without
this module and then with it, and the difference is what this module serves. That
buys the half of §5.3 that matters most for free — a module that shadowed or
displaced one of core's routes shows up as a REMOVAL, not merely as an addition
elsewhere. The same job checks the fragment against ground truth: every route
must have an operation and every operation must be a route.
**The release workflow** publishes `module-uo-<version>.tar.gz` plus a manifest
carrying its sha256. The version is declared in module.json rather than computed
from commit subjects, and the workflow never writes to a branch — it tags and
publishes — so `main` needs no push exception. The bundle is assembled from an
include list, because an exclude list ships whatever it forgot.
Four annotation defects, inherited from core and never visible until something
generated a spec from these files: two `requestBody` literals a brace short (the
route documented with an empty body), and two descriptions whose inner quoting
swagger-autogen cannot survive — it re-quotes `"` and a backtick to `'` before
evaluating, so either inside a single-quoted description ends the string early
and the annotation is dropped. It reports each one and then prints Success in
green, so the generator now captures its diagnostics and makes them fatal.
Also fixed while writing it: passing one shared `doc` to swagger-autogen six
times. It renders components.schemas from an EXAMPLE object and writes the result
back into what it was handed, so each pass re-wrapped the last and the fragment
came out at 484 MB.
- 409 server tests (+21), 40 client tests unchanged
- swagger-fragment.json: 69 paths covering all 72 routes
- routes.manifest.json: 72 routes; core's own surface unchanged, 0 removals
- verified end to end by assembling the bundle exactly as CI will, unpacking it
into a real core and regenerating the manifest
Refs: docs/website/MODULE_SYSTEM.md §2.7.1, MODULE_API.md §2.8, §5.3, §6.1a
Co-Authored-By: Claude <noreply@anthropic.com>
138 lines
8.2 KiB
JavaScript
138 lines
8.2 KiB
JavaScript
// Admin · uo-link — the sidecar connection config, the town crier, and the
|
|
// staff SSE stream.
|
|
//
|
|
// Mounted at /api/v1/admin/uo-link by admin/index.js, which already applied
|
|
// `noindex, isLoggedIn, staffOnly`. This is where shard integration is
|
|
// configured: base/ws URL, bearer token, protocol version and the enabled
|
|
// toggle all live in the DB (uoLinkConfig), never in env. The token is
|
|
// write-only over this API (SECURITY note in uoLink.controller.js).
|
|
//
|
|
// /stream is the ADMIN SSE channel — it carries staff audit, cheat detection
|
|
// and login attempts on top of the public event kinds. The public/admin
|
|
// allowlist split in utils/shardIngest.js is a security boundary; the adminOnly
|
|
// gate below is its other half.
|
|
//
|
|
// The routes keep their `Admin · Shard` swagger tag: retagging is a real
|
|
// OpenAPI diff and does not belong in a route-move PR.
|
|
//
|
|
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
|
// the middleware chain each route carries is unchanged by the move.
|
|
|
|
const core = require('../../core')
|
|
|
|
const express = core.express
|
|
const { body, param } = core.validator
|
|
|
|
const uoLink = require('./uoLink.controller')
|
|
const gameSignup = require('../../utils/gameSignup')
|
|
const { requireRole, validate } = core.middleware
|
|
|
|
const uoLinkRouter = express.Router()
|
|
const adminOnly = requireRole('admin')
|
|
|
|
uoLinkRouter.get(
|
|
'/config',
|
|
// #swagger.tags = ['Admin · Shard']
|
|
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
uoLink.getConfig,
|
|
)
|
|
uoLinkRouter.put(
|
|
'/config',
|
|
// #swagger.tags = ['Admin · Shard']
|
|
// #swagger.summary = 'Save uo-link connection config (admin only)'
|
|
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
|
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
|
|
body('token').optional({ values: 'falsy' }).isString().trim(),
|
|
body('protocol').optional().isInt({ min: 1, max: 99 }),
|
|
body('enabled').optional().isBoolean(),
|
|
validate,
|
|
uoLink.saveConfig,
|
|
)
|
|
|
|
// ── Game-account signup mode ───────────────────────────────────────────────
|
|
//
|
|
// New in slice 3, and new only in the sense that the field moved: core's Site
|
|
// Settings has carried `game_account_signup` since long before the extraction,
|
|
// and its help text has always been about a game server. The setting key and its
|
|
// stored value are unchanged, so an existing instance keeps its configured mode.
|
|
uoLinkRouter.get(
|
|
'/signup-mode',
|
|
// #swagger.tags = ['Admin · Shard']
|
|
// #swagger.summary = 'Get the game-account signup mode (admin only)'
|
|
// #swagger.description = 'Whether the site offers game-account creation, and in which direction. The shard\'s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.responses[200] = { description: 'The configured mode and the legal values', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" }, modes: { type: "array", items: { type: "string" } } } } } } } */
|
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
uoLink.getSignupMode,
|
|
)
|
|
uoLinkRouter.put(
|
|
'/signup-mode',
|
|
// #swagger.tags = ['Admin · Shard']
|
|
// #swagger.summary = 'Set the game-account signup mode (admin only)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["mode"], properties: { mode: { type: "string", enum: ["disabled","website","hybrid","game"] } } } } } } */
|
|
/* #swagger.responses[200] = { description: 'The saved mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" } } } } } } */
|
|
/* #swagger.responses[400] = { description: 'Unknown mode', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
// Validated here as well as in gameSignup.setMode: the list is the same list,
|
|
// and the difference is the answer. A rejected value must be a 400 naming the
|
|
// field, not a 500 from a thrown Error the controller could only guess about.
|
|
body('mode').isIn(gameSignup.MODES),
|
|
validate,
|
|
uoLink.saveSignupMode,
|
|
)
|
|
|
|
uoLinkRouter.post(
|
|
'/towncrier',
|
|
// #swagger.tags = ['Admin · Shard']
|
|
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoTownCrierRequest" } } } } */
|
|
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
body('id').isString().trim().isLength({ min: 1, max: 64 }),
|
|
body('lines').isArray({ min: 1, max: 8 }),
|
|
body('lines.*').isString().isLength({ max: 200 }),
|
|
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
|
|
validate,
|
|
uoLink.postTownCrier,
|
|
)
|
|
uoLinkRouter.delete(
|
|
'/towncrier/:id',
|
|
// #swagger.tags = ['Admin · Shard']
|
|
// #swagger.summary = 'Remove a town-crier message (admin only)'
|
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
|
|
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
adminOnly,
|
|
param('id').isString().trim().isLength({ min: 1, max: 64 }),
|
|
validate,
|
|
uoLink.deleteTownCrier,
|
|
)
|
|
uoLinkRouter.get(
|
|
'/stream',
|
|
// #swagger.tags = ['Admin · Shard']
|
|
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
|
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
|
adminOnly,
|
|
uoLink.stream,
|
|
)
|
|
|
|
module.exports = uoLinkRouter
|