`POST /player/shard/account` and its staff twin have answered 500 for every caller since slice 1: the ported controller called `settings.isGameAccountSignupEnabled()`, which is a member of core's settings model and not of `ctx.settings` — three functions, deliberately. The call was `undefined(...)`, the TypeError landed in the catch, and no test reached the branch. The gate now lives on the side that uses it (`utils/gameSignup.js`), which is also where the policy belongs: the setting's own help text names Bridge.cfg and says the shard's SignupMode must agree, and core cannot own a sentence about a UO shard. The admin field moves to this module's Shard page and the derived flag onto `/public/shard/features`, beside the visibility flags the same callers already read. The setting KEY is unchanged. Renaming `game_account_signup` would silently reset every configured instance to `disabled` on upgrade, with players reporting broken signup as the only clue — the same grandfathering as `spawn_atlas_servuo_path` and the seven stream ids. Both regression tests were shown to fail against the bug before it was fixed. 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/TownCrierRequest" } } } } */
|
|
/* #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
|