Nineteen `#swagger` descriptions carried a `\'` inside a single-quoted string. That is correct JavaScript and wrong here: swagger-autogen does not evaluate the annotation as JS, so the backslash survives into the spec and Swagger UI renders "the shard\'s published ruleset" to a reader. Replaced with a typographic apostrophe, which the same files already use elsewhere. Found by opening /api/docs in a browser against a real core with this module installed — the fragment was valid JSON, the paths were right, every test passed, and it was still wrong on screen. Nothing that reads the artifact can see this; only reading the rendered page can. Also documents the four environment variables this module reads (UOLINK_BASE_URL / _WS_URL / _PROTOCOL, TOWNCRIER_DURATION_SEC). Core's .env.example is dropping them in the paired website PR: they were never core's, and a half-copy in two repos goes stale silently. 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
|