`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>
153 lines
6.1 KiB
JavaScript
153 lines
6.1 KiB
JavaScript
// ── Admin: uo-link sidecar control ─────────────────────────────────────────
|
|
//
|
|
// Configure the connection to the uo-link sidecar (base/ws URL, shared-secret
|
|
// token, protocol pin, enabled) and drive the town crier. SECURITY: the token
|
|
// is write-only over this API — stored encrypted, NEVER returned; responses
|
|
// expose only `hasToken` (same convention as the Discord bot token). Saving
|
|
// (re)starts the WS ingest client so a change takes effect with no redeploy.
|
|
|
|
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
|
|
const uoLinkClient = require('../../utils/uoLinkClient')
|
|
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
|
const shardBroadcast = require('../../utils/shardBroadcast')
|
|
const gameSignup = require('../../utils/gameSignup')
|
|
const { activity } = require('../../core')
|
|
|
|
const log = require('../../core').logger('admin-uolink')
|
|
|
|
// Assemble the masked config + live health + ingestion stats for the panel.
|
|
async function buildStatus() {
|
|
const config = await uoLinkConfig.getSafe()
|
|
const health = await uoLinkClient.health()
|
|
return {
|
|
...config,
|
|
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
|
|
ingest: uoLinkSocket.getState(),
|
|
sse: shardBroadcast.stats(),
|
|
}
|
|
}
|
|
|
|
// GET /admin/uo-link/config — masked config + live status + ingestion stats.
|
|
async function getConfig(req, res) {
|
|
try {
|
|
return res.json(await buildStatus())
|
|
} catch (err) {
|
|
log.error('uoLink.getConfig', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// PUT /admin/uo-link/config — save connection settings + (re)start the socket.
|
|
async function saveConfig(req, res) {
|
|
const { baseUrl, wsUrl, token, protocol, enabled } = req.body
|
|
try {
|
|
const current = await uoLinkConfig.getSafe()
|
|
const willHaveToken = Boolean(token) || current.hasToken
|
|
if (enabled && !willHaveToken) {
|
|
return res.status(400).json({ message: 'An auth token is required before enabling.' })
|
|
}
|
|
|
|
await uoLinkConfig.save({
|
|
baseUrl,
|
|
wsUrl,
|
|
token,
|
|
protocol: protocol !== undefined ? Number(protocol) : undefined,
|
|
enabled,
|
|
updatedBy: req.user.id,
|
|
})
|
|
// Drop the client's cached config so the health check below uses the new values.
|
|
uoLinkClient.invalidateConfig()
|
|
|
|
// (Re)start or stop the ingest socket to match the new enabled/URL/token.
|
|
const saved = await uoLinkConfig.getSafe()
|
|
if (saved.enabled && saved.hasToken) {
|
|
await uoLinkSocket.start()
|
|
} else {
|
|
uoLinkSocket.stop()
|
|
await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false })
|
|
}
|
|
|
|
await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } })
|
|
log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled })
|
|
return res.json(await buildStatus())
|
|
} catch (err) {
|
|
log.error('uoLink.saveConfig', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/uo-link/signup-mode — whether this site creates game accounts.
|
|
//
|
|
// Core's Site Settings carried this field until slice 3, with help text naming
|
|
// Bridge.cfg. It reads as UO policy because it is: the site's mode and the
|
|
// shard's own SignupMode have to agree, and only one of those two is core's.
|
|
async function getSignupMode(req, res) {
|
|
try {
|
|
return res.json({ mode: await gameSignup.getMode(), modes: gameSignup.MODES })
|
|
} catch (err) {
|
|
log.error('uoLink.getSignupMode', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// PUT /admin/uo-link/signup-mode
|
|
async function saveSignupMode(req, res) {
|
|
const { mode } = req.body
|
|
try {
|
|
await gameSignup.setMode(mode, req.user.id)
|
|
await activity.log({ req, action: 'uoLink.signupMode.update', detail: { mode } })
|
|
log.info('game-signup mode updated', { by: req.user.username, mode })
|
|
return res.json({ mode })
|
|
} catch (err) {
|
|
log.error('uoLink.saveSignupMode', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// POST /admin/uo-link/towncrier — publish/replace a town-crier message.
|
|
async function postTownCrier(req, res) {
|
|
const { id, lines, durationSec } = req.body
|
|
try {
|
|
const result = await uoLinkClient.postTownCrier({ id, lines, durationSec })
|
|
if (result.ok) {
|
|
await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } })
|
|
return res.json(result.data || { ok: true, id })
|
|
}
|
|
if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' })
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
|
} catch (err) {
|
|
log.error('uoLink.postTownCrier', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// DELETE /admin/uo-link/towncrier/:id — remove a town-crier message.
|
|
async function deleteTownCrier(req, res) {
|
|
const { id } = req.params
|
|
try {
|
|
const result = await uoLinkClient.deleteTownCrier(id)
|
|
if (result.ok) {
|
|
await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } })
|
|
return res.json(result.data || { ok: true, id })
|
|
}
|
|
if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' })
|
|
if (result.status === 503 || result.status === 0) {
|
|
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
|
}
|
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
|
} catch (err) {
|
|
log.error('uoLink.deleteTownCrier', err)
|
|
return res.status(500).json({ message: 'Internal Server Error' })
|
|
}
|
|
}
|
|
|
|
// GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only.
|
|
function stream(req, res) {
|
|
shardBroadcast.subscribe(req, res, 'admin')
|
|
}
|
|
|
|
module.exports = { getConfig, saveConfig, getSignupMode, saveSignupMode, postTownCrier, deleteTownCrier, stream }
|