chore(quality): resolve SonarQube code smells across website
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,62 @@ function validateBlocks(blocks) {
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
||||
function checkEnvelope(block, path, errors) {
|
||||
for (const key of Object.keys(block)) {
|
||||
if (!RESERVED_KEYS.includes(key)) {
|
||||
errors.push(`${path}.${key} is not an allowed top-level key`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// id — stable, unique across the whole page (top-level and nested share one
|
||||
// namespace since ids are the future join point for revision history).
|
||||
function checkId(block, path, seenIds, errors) {
|
||||
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
|
||||
errors.push(`${path}.id must be a short id string`)
|
||||
} else if (seenIds.has(block.id)) {
|
||||
errors.push(`${path}.id duplicates another block id (${block.id})`)
|
||||
} else {
|
||||
seenIds.add(block.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-block prop schema from the registry (skipped when props isn't an object —
|
||||
// that's already reported separately).
|
||||
function checkPropSchema(def, props, path, errors) {
|
||||
if (!def.schema || !props || typeof props !== 'object') return
|
||||
let schemaErrors = []
|
||||
try {
|
||||
schemaErrors = def.schema(props) || []
|
||||
} catch (err) {
|
||||
schemaErrors = [`schema threw: ${err.message}`]
|
||||
}
|
||||
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
|
||||
}
|
||||
|
||||
// Nesting: only container blocks may hold sub-blocks, capped at one level.
|
||||
function checkNesting(def, props, path, seenIds, errors, nested) {
|
||||
if (nested) {
|
||||
errors.push(`${path} is a container and may not be nested inside another container`)
|
||||
return
|
||||
}
|
||||
for (const slot of def.containerSlots) {
|
||||
const sub = props ? props[slot] : undefined
|
||||
if (sub === undefined) continue // an empty slot is allowed
|
||||
if (!Array.isArray(sub)) {
|
||||
errors.push(`${path}.props.${slot} must be an array of blocks`)
|
||||
continue
|
||||
}
|
||||
if (sub.length > MAX_SUBBLOCKS) {
|
||||
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
|
||||
}
|
||||
sub.forEach((child, j) => {
|
||||
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one block envelope in place. `nested` = true when validating a
|
||||
* sub-block inside a container slot, which forbids further nesting.
|
||||
@@ -47,22 +103,8 @@ function validateBlock(block, path, seenIds, errors, { nested }) {
|
||||
return
|
||||
}
|
||||
|
||||
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
||||
for (const key of Object.keys(block)) {
|
||||
if (!RESERVED_KEYS.includes(key)) {
|
||||
errors.push(`${path}.${key} is not an allowed top-level key`)
|
||||
}
|
||||
}
|
||||
|
||||
// id — stable, unique across the whole page (top-level and nested share one
|
||||
// namespace since ids are the future join point for revision history).
|
||||
if (typeof block.id !== 'string' || !ID_RE.test(block.id)) {
|
||||
errors.push(`${path}.id must be a short id string`)
|
||||
} else if (seenIds.has(block.id)) {
|
||||
errors.push(`${path}.id duplicates another block id (${block.id})`)
|
||||
} else {
|
||||
seenIds.add(block.id)
|
||||
}
|
||||
checkEnvelope(block, path, errors)
|
||||
checkId(block, path, seenIds, errors)
|
||||
|
||||
// visible — optional in input, but if present must be a boolean.
|
||||
if (block.visible !== undefined && typeof block.visible !== 'boolean') {
|
||||
@@ -82,38 +124,8 @@ function validateBlock(block, path, seenIds, errors, { nested }) {
|
||||
return // can't validate props or nesting without a definition
|
||||
}
|
||||
|
||||
// Per-block prop schema from the registry.
|
||||
if (def.schema && props && typeof props === 'object') {
|
||||
let schemaErrors = []
|
||||
try {
|
||||
schemaErrors = def.schema(props) || []
|
||||
} catch (err) {
|
||||
schemaErrors = [`schema threw: ${err.message}`]
|
||||
}
|
||||
for (const e of schemaErrors) errors.push(`${path}.props.${e}`)
|
||||
}
|
||||
|
||||
// Nesting: only container blocks may hold sub-blocks, capped at one level.
|
||||
if (def.container) {
|
||||
if (nested) {
|
||||
errors.push(`${path} is a container and may not be nested inside another container`)
|
||||
return
|
||||
}
|
||||
for (const slot of def.containerSlots) {
|
||||
const sub = props ? props[slot] : undefined
|
||||
if (sub === undefined) continue // an empty slot is allowed
|
||||
if (!Array.isArray(sub)) {
|
||||
errors.push(`${path}.props.${slot} must be an array of blocks`)
|
||||
continue
|
||||
}
|
||||
if (sub.length > MAX_SUBBLOCKS) {
|
||||
errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`)
|
||||
}
|
||||
sub.forEach((child, j) => {
|
||||
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
checkPropSchema(def, props, path, errors)
|
||||
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
|
||||
}
|
||||
|
||||
module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
|
||||
|
||||
@@ -92,6 +92,60 @@ function createTracker() {
|
||||
}
|
||||
const defaultTracker = createTracker()
|
||||
|
||||
// Per-kind mappers, each pushing 0+ targets onto `out` (and updating `tracker`
|
||||
// for the upsert-transition kinds). Split out of mapShardEvent so that function
|
||||
// stays a trivial dispatch + the public-safety filter.
|
||||
const serverStatusUp = (event, tracker, out) =>
|
||||
out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` })
|
||||
const serverStatusDown = (event, tracker, out) => out.push({ streamId: 'server.status', ref: 'down' })
|
||||
|
||||
const EVENT_MAPPERS = {
|
||||
'server.hello': serverStatusUp,
|
||||
'server.shutdown': serverStatusDown,
|
||||
'server.crashed': serverStatusDown,
|
||||
'house.decay': (event, tracker, out) => {
|
||||
if (String(event.to).toUpperCase() !== 'IDOC') return
|
||||
const ref = String(event.serial ?? '')
|
||||
out.push({ streamId: 'idoc.warning', ref }) // public — location only
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal
|
||||
}
|
||||
},
|
||||
'champ.update': (event, tracker, out) => {
|
||||
const { serial } = event
|
||||
if (serial == null) return
|
||||
const wasActive = tracker.champActive.get(serial) === true
|
||||
const isActive = event.active === true
|
||||
tracker.champActive.set(serial, isActive)
|
||||
if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) })
|
||||
},
|
||||
'champ.remove': (event, tracker) => {
|
||||
if (event.serial != null) tracker.champActive.delete(event.serial)
|
||||
},
|
||||
'city.update': (event, tracker, out) => {
|
||||
const { city } = event
|
||||
if (!city) return
|
||||
const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null
|
||||
const prev = tracker.cityGovernor.get(city)
|
||||
tracker.cityGovernor.set(city, gov)
|
||||
// Only a real transition to a new governor, and never on first sight
|
||||
// (prev === undefined) so a reconnect snapshot isn't read as an election.
|
||||
if (prev !== undefined && gov && gov !== prev) {
|
||||
out.push({ streamId: 'governor.election', ref: String(city) })
|
||||
}
|
||||
},
|
||||
'vendor.sale': (event, tracker, out) => {
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct })
|
||||
}
|
||||
},
|
||||
'account.login.attempt': (event, tracker, out) => {
|
||||
if (event.acct) {
|
||||
out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }).
|
||||
// May yield 0, 1, or 2 targets (an owner house.decay produces both the public
|
||||
// idoc.warning and the personal house.idoc). Pure given `tracker`.
|
||||
@@ -100,61 +154,8 @@ function mapShardEvent(event, tracker = defaultTracker) {
|
||||
const kind = event.kind
|
||||
const out = []
|
||||
|
||||
switch (kind) {
|
||||
case 'server.hello':
|
||||
out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` })
|
||||
break
|
||||
case 'server.shutdown':
|
||||
case 'server.crashed':
|
||||
out.push({ streamId: 'server.status', ref: 'down' })
|
||||
break
|
||||
case 'house.decay': {
|
||||
if (String(event.to).toUpperCase() !== 'IDOC') break
|
||||
const ref = String(event.serial ?? '')
|
||||
out.push({ streamId: 'idoc.warning', ref }) // public — location only
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'champ.update': {
|
||||
const { serial } = event
|
||||
if (serial == null) break
|
||||
const wasActive = tracker.champActive.get(serial) === true
|
||||
const isActive = event.active === true
|
||||
tracker.champActive.set(serial, isActive)
|
||||
if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) })
|
||||
break
|
||||
}
|
||||
case 'champ.remove':
|
||||
if (event.serial != null) tracker.champActive.delete(event.serial)
|
||||
break
|
||||
case 'city.update': {
|
||||
const { city } = event
|
||||
if (!city) break
|
||||
const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null
|
||||
const prev = tracker.cityGovernor.get(city)
|
||||
tracker.cityGovernor.set(city, gov)
|
||||
// Only a real transition to a new governor, and never on first sight
|
||||
// (prev === undefined) so a reconnect snapshot isn't read as an election.
|
||||
if (prev !== undefined && gov && gov !== prev) {
|
||||
out.push({ streamId: 'governor.election', ref: String(city) })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'vendor.sale':
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct })
|
||||
}
|
||||
break
|
||||
case 'account.login.attempt':
|
||||
if (event.acct) {
|
||||
out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct })
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
const mapper = EVENT_MAPPERS[kind]
|
||||
if (mapper) mapper(event, tracker, out)
|
||||
|
||||
// Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe
|
||||
// kind. Personal targets are owner-keyed and delivered solely to the owner, so
|
||||
|
||||
@@ -48,6 +48,7 @@ const HONEYPOT_POINTS = BAN_THRESHOLD
|
||||
// access logs (from many rotating IPs), so it carries the single highest weight:
|
||||
// a lone hit exceeds the ban threshold on its own — effectively a 1-hit ban —
|
||||
// and outweighs every other individual path.
|
||||
/** @type {Array<[string, number]>} — scanner path prefix → score weight. */
|
||||
const PATH_WEIGHTS = [
|
||||
['/wp-admin/install.php', 200], // top offender in prod logs — near 1-hit ban
|
||||
['/.env', 100],
|
||||
|
||||
@@ -10,8 +10,8 @@ async function log({ req, userId, action, detail }) {
|
||||
try {
|
||||
const resolvedUserId = userId ?? (req && req.user ? req.user.id : null)
|
||||
const ip = req ? req.ip : null
|
||||
const detailStr =
|
||||
detail == null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
|
||||
let detailStr = null
|
||||
if (detail != null) detailStr = typeof detail === 'string' ? detail : JSON.stringify(detail)
|
||||
await activityDb.insert({ userId: resolvedUserId, action, detail: detailStr, ip })
|
||||
} catch (err) {
|
||||
logger.error(`failed to record action "${action}"`, { error: err.message })
|
||||
|
||||
@@ -40,7 +40,7 @@ function reshapeWindows(rows) {
|
||||
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
|
||||
function windowValue(row, key) {
|
||||
if (!row) return 0
|
||||
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
|
||||
const col = { '24h': row.d1, '7d': row.d7 }[key] ?? row.d30
|
||||
return Number(col) || 0
|
||||
}
|
||||
|
||||
|
||||
@@ -130,35 +130,49 @@ function mapMetadata(metadata) {
|
||||
return cols
|
||||
}
|
||||
|
||||
// Per-setting validators — each throws a 400 or returns the accepted value. Split
|
||||
// out of mapSettings so that function stays a flat dispatch (keeps its cognitive
|
||||
// complexity low).
|
||||
function validLayout(v) {
|
||||
if (!LAYOUTS.includes(v)) {
|
||||
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
|
||||
}
|
||||
return v
|
||||
}
|
||||
function validShowInNav(v) {
|
||||
if (typeof v !== 'boolean') {
|
||||
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
|
||||
}
|
||||
return v ? 1 : 0
|
||||
}
|
||||
function validNavGroup(v) {
|
||||
if (v !== null && !NAV_GROUPS.includes(v)) {
|
||||
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
|
||||
}
|
||||
return v
|
||||
}
|
||||
function validNavOrder(v) {
|
||||
if (v !== null && !Number.isInteger(v)) {
|
||||
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
|
||||
}
|
||||
return v
|
||||
}
|
||||
function validTitle(v) {
|
||||
if (typeof v !== 'string' || v.trim() === '' || v.length > 200) {
|
||||
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
|
||||
}
|
||||
return v.trim()
|
||||
}
|
||||
|
||||
// Map the grouped `settings` object to DB columns (except `protected`, which is
|
||||
// handled by the caller so the unprotect rule stays centralized).
|
||||
function mapSettings(settings) {
|
||||
const cols = {}
|
||||
if (!settings || typeof settings !== 'object') return cols
|
||||
if ('layout' in settings) {
|
||||
if (!LAYOUTS.includes(settings.layout)) {
|
||||
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
|
||||
}
|
||||
cols.layout = settings.layout
|
||||
}
|
||||
if ('showInNav' in settings) {
|
||||
if (typeof settings.showInNav !== 'boolean') {
|
||||
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
|
||||
}
|
||||
cols.show_in_nav = settings.showInNav ? 1 : 0
|
||||
}
|
||||
if ('navGroup' in settings) {
|
||||
if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) {
|
||||
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
|
||||
}
|
||||
cols.nav_group = settings.navGroup
|
||||
}
|
||||
if ('navOrder' in settings) {
|
||||
if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) {
|
||||
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
|
||||
}
|
||||
cols.nav_order = settings.navOrder
|
||||
}
|
||||
if ('layout' in settings) cols.layout = validLayout(settings.layout)
|
||||
if ('showInNav' in settings) cols.show_in_nav = validShowInNav(settings.showInNav)
|
||||
if ('navGroup' in settings) cols.nav_group = validNavGroup(settings.navGroup)
|
||||
if ('navOrder' in settings) cols.nav_order = validNavOrder(settings.navOrder)
|
||||
return cols
|
||||
}
|
||||
|
||||
@@ -191,13 +205,10 @@ async function create(input, authorId) {
|
||||
const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
|
||||
assertSlug(slug)
|
||||
assertStatus(status)
|
||||
if (typeof title !== 'string' || title.trim() === '' || title.length > 200) {
|
||||
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
|
||||
}
|
||||
|
||||
const row = {
|
||||
slug,
|
||||
title: title.trim(),
|
||||
title: validTitle(title),
|
||||
blocks: buildBlocks(blocks),
|
||||
status,
|
||||
author_id: authorId,
|
||||
@@ -219,6 +230,26 @@ async function create(input, authorId) {
|
||||
return getById(id)
|
||||
}
|
||||
|
||||
// Apply the status patch, stamping published_at the first time a page publishes.
|
||||
function applyStatusPatch(fields, status, current) {
|
||||
assertStatus(status)
|
||||
fields.status = status
|
||||
if (status === 'published' && !current.published_at) fields.published_at = new Date()
|
||||
}
|
||||
|
||||
// Protected transitions: turning protection ON is allowed here; turning it OFF is
|
||||
// not (must go through the password-gated unprotect endpoint), regardless of the
|
||||
// request body. Turning OFF while already unprotected is a no-op.
|
||||
function applyProtectedPatch(fields, settings, current) {
|
||||
if (!settings || !('protected' in settings)) return
|
||||
const want = settings.protected
|
||||
if (want === true) {
|
||||
fields.protected = 1
|
||||
} else if (want === false && current.protected) {
|
||||
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
const current = await pagesDb.findById(id)
|
||||
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
|
||||
@@ -230,41 +261,13 @@ async function update(id, patch) {
|
||||
}
|
||||
|
||||
const fields = {}
|
||||
|
||||
if (patch.title !== undefined) {
|
||||
if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) {
|
||||
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
|
||||
}
|
||||
fields.title = patch.title.trim()
|
||||
}
|
||||
|
||||
if (patch.blocks !== undefined) {
|
||||
fields.blocks = buildBlocks(patch.blocks)
|
||||
}
|
||||
|
||||
if (patch.status !== undefined) {
|
||||
assertStatus(patch.status)
|
||||
fields.status = patch.status
|
||||
// Stamp published_at the first time a page becomes published.
|
||||
if (patch.status === 'published' && !current.published_at) {
|
||||
fields.published_at = new Date()
|
||||
}
|
||||
}
|
||||
if (patch.title !== undefined) fields.title = validTitle(patch.title)
|
||||
if (patch.blocks !== undefined) fields.blocks = buildBlocks(patch.blocks)
|
||||
if (patch.status !== undefined) applyStatusPatch(fields, patch.status, current)
|
||||
|
||||
Object.assign(fields, mapMetadata(patch.metadata))
|
||||
Object.assign(fields, mapSettings(patch.settings))
|
||||
|
||||
// Protected transitions: ON is allowed here; OFF is not (must go through the
|
||||
// password-gated unprotect endpoint), regardless of the request body.
|
||||
if (patch.settings && 'protected' in patch.settings) {
|
||||
const want = patch.settings.protected
|
||||
if (want === true) {
|
||||
fields.protected = 1
|
||||
} else if (want === false && current.protected) {
|
||||
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
|
||||
}
|
||||
// want === false while already unprotected → no-op.
|
||||
}
|
||||
applyProtectedPatch(fields, patch.settings, current)
|
||||
|
||||
await pagesDb.update(id, fields)
|
||||
return getById(id)
|
||||
|
||||
@@ -15,7 +15,8 @@ function stableStringify(value) {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||
const keys = Object.keys(value).sort()
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
||||
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
||||
return `{${entries.join(',')}}`
|
||||
}
|
||||
|
||||
// dedupe_key = sha256(kind + t + stable-json(payload)), truncated to 40 hex chars.
|
||||
|
||||
@@ -54,9 +54,12 @@ const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
|
||||
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
|
||||
// effect immediately, regardless of whether the player has re-logged since
|
||||
// linking, then through to users so only staff roles are surfaced publicly.
|
||||
const listOnlineLinked = () =>
|
||||
query(
|
||||
`SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')}
|
||||
const listOnlineLinked = () => {
|
||||
const cols = ONLINE_COLS.split(', ')
|
||||
.map((c) => `o.${c}`)
|
||||
.join(', ')
|
||||
return query(
|
||||
`SELECT ${cols}
|
||||
FROM shard_online o
|
||||
JOIN shard_account_links l ON l.account = o.acct
|
||||
JOIN users u ON u.id = l.user_id
|
||||
@@ -64,6 +67,7 @@ const listOnlineLinked = () =>
|
||||
ORDER BY o.name ASC`,
|
||||
PUBLIC_ONLINE_ROLES,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Economy supply series ────────────────────────────────────────────────
|
||||
const insertEconomy = ({ accounts, gold, t }) =>
|
||||
|
||||
@@ -8,6 +8,17 @@ const db = require('./shardState.db')
|
||||
|
||||
const MAX_ECONOMY = 1000
|
||||
|
||||
// Small coercion helpers, kept out of the upsert builders below so those stay
|
||||
// flat (each inline `?? null` / ternary otherwise adds to cognitive complexity).
|
||||
const orNull = (v) => v ?? null
|
||||
const toDate = (v) => (v ? new Date(v) : null)
|
||||
// Owner is an actor object (or null for an abandoned house); flatten to columns.
|
||||
const ownerFields = (owner) => ({
|
||||
owner_serial: orNull(owner?.serial),
|
||||
owner_acct: orNull(owner?.acct),
|
||||
owner_name: orNull(owner?.name),
|
||||
})
|
||||
|
||||
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
|
||||
// partial refresh only touches the fields it carries.
|
||||
function onlineFields(data) {
|
||||
@@ -180,23 +191,20 @@ async function listHousesForAccounts(accounts) {
|
||||
// actor object (or null for an abandoned house).
|
||||
async function upsertHouseRegistry(data) {
|
||||
if (!data || !data.serial) return
|
||||
const owner = data.owner || null
|
||||
const fields = {
|
||||
name: data.name ?? null,
|
||||
owner_serial: owner ? owner.serial ?? null : null,
|
||||
owner_acct: owner ? owner.acct ?? null : null,
|
||||
owner_name: owner ? owner.name ?? null : null,
|
||||
co_owners: data.coOwners ?? null,
|
||||
friends: data.friends ?? null,
|
||||
price: data.price ?? null,
|
||||
decay: data.decay ?? null,
|
||||
region: data.region ?? null,
|
||||
map: data.map ?? null,
|
||||
x: data.x ?? null,
|
||||
y: data.y ?? null,
|
||||
z: data.z ?? null,
|
||||
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||
name: orNull(data.name),
|
||||
...ownerFields(data.owner || null),
|
||||
co_owners: orNull(data.coOwners),
|
||||
friends: orNull(data.friends),
|
||||
price: orNull(data.price),
|
||||
decay: orNull(data.decay),
|
||||
region: orNull(data.region),
|
||||
map: orNull(data.map),
|
||||
x: orNull(data.x),
|
||||
y: orNull(data.y),
|
||||
z: orNull(data.z),
|
||||
built_on: toDate(data.builtOn),
|
||||
last_refreshed: toDate(data.lastRefreshed),
|
||||
in_registry: 1,
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
@@ -222,15 +230,15 @@ async function listOnlineForAccounts(accounts) {
|
||||
async function upsertChamp(ev) {
|
||||
if (!ev || !ev.serial) return
|
||||
await db.upsertChamp(ev.serial, {
|
||||
category: ev.category ?? null,
|
||||
type: ev.type ?? null,
|
||||
name: ev.name ?? null,
|
||||
status: ev.status ?? null,
|
||||
category: orNull(ev.category),
|
||||
type: orNull(ev.type),
|
||||
name: orNull(ev.name),
|
||||
status: orNull(ev.status),
|
||||
active: ev.active ? 1 : 0,
|
||||
map: ev.map ?? null,
|
||||
x: ev.x ?? null,
|
||||
y: ev.y ?? null,
|
||||
z: ev.z ?? null,
|
||||
map: orNull(ev.map),
|
||||
x: orNull(ev.x),
|
||||
y: orNull(ev.y),
|
||||
z: orNull(ev.z),
|
||||
boss_up: ev.bossUp ? 1 : 0,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
@@ -280,18 +288,18 @@ async function upsertPage(ev) {
|
||||
if (!pageId) return
|
||||
const sender = ev.sender || {}
|
||||
await db.upsertPage(pageId, {
|
||||
type: ev.type ?? null,
|
||||
sender_name: sender.name ?? null,
|
||||
sender_acct: sender.acct ?? null,
|
||||
web_id: sender.webId ?? null,
|
||||
message: ev.message ?? null,
|
||||
map: ev.map ?? null,
|
||||
x: ev.x ?? null,
|
||||
y: ev.y ?? null,
|
||||
z: ev.z ?? null,
|
||||
type: orNull(ev.type),
|
||||
sender_name: orNull(sender.name),
|
||||
sender_acct: orNull(sender.acct),
|
||||
web_id: orNull(sender.webId),
|
||||
message: orNull(ev.message),
|
||||
map: orNull(ev.map),
|
||||
x: orNull(ev.x),
|
||||
y: orNull(ev.y),
|
||||
z: orNull(ev.z),
|
||||
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
|
||||
handled: ev.handled ? 1 : 0,
|
||||
handler: ev.handler ?? null,
|
||||
handler: orNull(ev.handler),
|
||||
payload: JSON.stringify(ev),
|
||||
})
|
||||
}
|
||||
@@ -406,19 +414,19 @@ async function listGuildsLedForAccounts(accounts) {
|
||||
async function upsertGovernor(ev) {
|
||||
if (!ev || !ev.city) return
|
||||
await recordGovernorTransition(ev)
|
||||
const gov = ev.governor || null
|
||||
const elect = ev.governorElect || null
|
||||
const gov = ev.governor
|
||||
const elect = ev.governorElect
|
||||
await db.upsertGovernor(ev.city, {
|
||||
governor_serial: gov ? gov.serial ?? null : null,
|
||||
governor_name: gov ? gov.name ?? null : null,
|
||||
governor_acct: gov ? gov.acct ?? null : null,
|
||||
governor_web_id: gov ? gov.webId ?? null : null,
|
||||
elect_serial: elect ? elect.serial ?? null : null,
|
||||
elect_name: elect ? elect.name ?? null : null,
|
||||
elect_acct: elect ? elect.acct ?? null : null,
|
||||
election_phase: ev.electionPhase ?? null,
|
||||
candidates: ev.candidates ?? null,
|
||||
auto_pick_at: ev.autoPickAt ? new Date(ev.autoPickAt) : null,
|
||||
governor_serial: orNull(gov?.serial),
|
||||
governor_name: orNull(gov?.name),
|
||||
governor_acct: orNull(gov?.acct),
|
||||
governor_web_id: orNull(gov?.webId),
|
||||
elect_serial: orNull(elect?.serial),
|
||||
elect_name: orNull(elect?.name),
|
||||
elect_acct: orNull(elect?.acct),
|
||||
election_phase: orNull(ev.electionPhase),
|
||||
candidates: orNull(ev.candidates),
|
||||
auto_pick_at: toDate(ev.autoPickAt),
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
|
||||
@@ -98,11 +98,10 @@ async function create({ slug, title, body, excerpt, categoryId, published, updat
|
||||
return getBySlug(slug)
|
||||
}
|
||||
|
||||
// Partial update — only keys present in `input` are written.
|
||||
async function update(slug, input) {
|
||||
const current = await wikiDb.findBySlug(slug)
|
||||
if (!current) return null
|
||||
|
||||
// Map the partial `input` to DB columns (only keys present are written), and
|
||||
// report the cleaned body for link rebuilding when the body changed. Split out of
|
||||
// update() so that stays a flat sequence of write steps.
|
||||
function mapUpdateFields(input, current) {
|
||||
const fields = { updated_by: input.updatedBy ?? null }
|
||||
let cleanForLinks = null
|
||||
if ('title' in input) fields.title = input.title
|
||||
@@ -116,7 +115,15 @@ async function update(slug, input) {
|
||||
fields.published = input.published ? 1 : 0
|
||||
if (input.published && !current.published_at) fields.published_at = new Date()
|
||||
}
|
||||
return { fields, cleanForLinks }
|
||||
}
|
||||
|
||||
// Partial update — only keys present in `input` are written.
|
||||
async function update(slug, input) {
|
||||
const current = await wikiDb.findBySlug(slug)
|
||||
if (!current) return null
|
||||
|
||||
const { fields, cleanForLinks } = mapUpdateFields(input, current)
|
||||
await wikiDb.updateBySlug(slug, fields)
|
||||
if (Array.isArray(input.tags)) await syncTags(current.id, input.tags)
|
||||
if (cleanForLinks != null) await rebuildLinks(current.id, cleanForLinks)
|
||||
|
||||
@@ -1113,7 +1113,7 @@ adminRouter.get(
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
param('discordId').matches(/^d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUser,
|
||||
)
|
||||
@@ -1122,7 +1122,7 @@ adminRouter.get(
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Full moderation action history for a user'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
param('discordId').matches(/^d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserActions,
|
||||
)
|
||||
@@ -1131,7 +1131,7 @@ adminRouter.get(
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Staff notes for a user (admin_only notes hidden from moderators)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
param('discordId').matches(/^d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserNotes,
|
||||
)
|
||||
@@ -1140,7 +1140,7 @@ adminRouter.post(
|
||||
// #swagger.tags = ['Admin · Moderation']
|
||||
// #swagger.summary = 'Add a staff note (admin_only visibility requires the admin role)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
param('discordId').matches(/^d{1,32}$/),
|
||||
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
|
||||
body('visibility').optional().isIn(['staff_only', 'admin_only']),
|
||||
validate,
|
||||
@@ -1209,7 +1209,7 @@ adminRouter.get(
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['discordId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Discord snowflake.' }
|
||||
/* #swagger.responses[200] = { description: 'Appeals for the user', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealQueueItem" } } } } } */
|
||||
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||
param('discordId').matches(/^d{1,32}$/),
|
||||
validate,
|
||||
moderation.getUserAppeals,
|
||||
)
|
||||
|
||||
@@ -26,8 +26,7 @@ async function isValidBotToken(token) {
|
||||
const res = await fetch('https://discord.com/api/users/@me', {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
})
|
||||
if (res.status === 401) return false
|
||||
return true
|
||||
return res.status !== 401
|
||||
} catch (err) {
|
||||
log.warn('discord token validation unreachable — not blocking save', { message: err.message })
|
||||
return true
|
||||
|
||||
@@ -321,31 +321,58 @@ async function mintMobileAuthLink(req, sess, user, providerId, viaTotp) {
|
||||
// code and 302s to the app callback instead of setting a session cookie. A 2FA
|
||||
// account is routed through the same web TOTP form (carrying the bridge session)
|
||||
// and completes in finishSsoTotp — the second factor is never bypassed.
|
||||
async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||
// Validate the mobile bridge session. Returns the session, or sends the failure
|
||||
// response (redirect when we still have a session for its redirect_uri, else a
|
||||
// generic 400) and returns null so the caller stops.
|
||||
async function requireValidBridgeSession(res, tx, providerId) {
|
||||
const sess = await mobileBridge.getSession(tx.mobileSessionId)
|
||||
if (!sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()) {
|
||||
log.warn('mobile sso callback: bridge session invalid/expired', { provider: providerId })
|
||||
// Without a valid session we can't trust a redirect_uri — fail generically.
|
||||
if (sess) return res.redirect(appError(sess, 'session_expired'))
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'This sign-in session is invalid or has expired. Please try again from the app.' })
|
||||
const invalid = !sess || sess.status !== 'pending' || new Date(sess.expires_at).getTime() <= Date.now()
|
||||
if (!invalid) return sess
|
||||
log.warn('mobile sso callback: bridge session invalid/expired', { provider: providerId })
|
||||
// Without a valid session we can't trust a redirect_uri — fail generically.
|
||||
if (sess) {
|
||||
res.redirect(appError(sess, 'session_expired'))
|
||||
return null
|
||||
}
|
||||
res
|
||||
.status(400)
|
||||
.json({ message: 'This sign-in session is invalid or has expired. Please try again from the app.' })
|
||||
return null
|
||||
}
|
||||
|
||||
let user
|
||||
// Resolve the linked user for a mobile SSO login (link-only, with opt-in
|
||||
// provisioning when registration mode allows it). On refusal, sends the redirect
|
||||
// and returns null.
|
||||
async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
|
||||
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||
if (identity) {
|
||||
user = await users.getById(identity.user_id)
|
||||
if (!user) return res.redirect(appError(sess, 'not_linked'))
|
||||
} else {
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
||||
return res.redirect(appError(sess, 'not_linked'))
|
||||
const user = await users.getById(identity.user_id)
|
||||
if (!user) {
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
}
|
||||
user = await provisionSsoPlayer(req, providerId, profile)
|
||||
if (!user) return res.redirect(appError(sess, 'error'))
|
||||
return user
|
||||
}
|
||||
const mode = await settings.getRegistrationMode()
|
||||
if (mode !== 'sso' && mode !== 'both') {
|
||||
log.warn('mobile sso login refused: no linked account', { provider: providerId })
|
||||
res.redirect(appError(sess, 'not_linked'))
|
||||
return null
|
||||
}
|
||||
const user = await provisionSsoPlayer(req, providerId, profile)
|
||||
if (!user) {
|
||||
res.redirect(appError(sess, 'error'))
|
||||
return null
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
|
||||
const sess = await requireValidBridgeSession(res, tx, providerId)
|
||||
if (!sess) return
|
||||
|
||||
const user = await resolveMobileSsoUser(req, res, sess, providerId, profile)
|
||||
if (!user) return
|
||||
|
||||
if (user.status && user.status !== 'active') {
|
||||
log.warn('mobile sso login refused: inactive account', { provider: providerId, id: user.id, status: user.status })
|
||||
|
||||
@@ -188,8 +188,10 @@ async function applyStateChange(event, deps) {
|
||||
// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill`
|
||||
// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the
|
||||
// live ticker). Never throws — a bad single event must not kill the feed.
|
||||
async function ingest(event, deps = {}) {
|
||||
const d = {
|
||||
// Resolve the injectable dependencies to their live defaults (tests override a
|
||||
// subset). Split out so ingest() isn't penalised for the fan of `|| default`s.
|
||||
function resolveDeps(deps) {
|
||||
return {
|
||||
shardEvents: deps.shardEvents || shardEventsModel,
|
||||
shardState: deps.shardState || shardStateModel,
|
||||
shardLinks: deps.shardLinks || shardLinksModel,
|
||||
@@ -198,6 +200,10 @@ async function ingest(event, deps = {}) {
|
||||
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
}
|
||||
|
||||
async function ingest(event, deps = {}) {
|
||||
const d = resolveDeps(deps)
|
||||
|
||||
if (!event || typeof event.kind !== 'string') return { logged: false, stored: false }
|
||||
// ws.hello / pong are transport frames, not game events.
|
||||
|
||||
@@ -97,7 +97,8 @@ function getHistory({ kind, limit = 100 } = {}) {
|
||||
if (kind) params.set('kind', kind)
|
||||
if (limit) params.set('limit', String(limit))
|
||||
const qs = params.toString()
|
||||
return call(`/history${qs ? `?${qs}` : ''}`)
|
||||
const suffix = qs ? `?${qs}` : ''
|
||||
return call(`/history${suffix}`)
|
||||
}
|
||||
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
|
||||
// Live board / queue projections — snapshotted on WS (re)connect and served from
|
||||
|
||||
@@ -43,39 +43,40 @@ function buildUrl(wsUrl, token) {
|
||||
return token ? `${wsUrl}${sep}token=${encodeURIComponent(token)}` : wsUrl
|
||||
}
|
||||
|
||||
// One guarded board snapshot: fetch, verify `data[key]` is an array, hand it to
|
||||
// `apply`, and (when given) log `label` with the row count. Isolated so a
|
||||
// failed/absent board never aborts the rest of backfill — and so backfill()
|
||||
// stays a flat sequence rather than nine repetitions of the same guard.
|
||||
async function snapshot(fetchFn, key, apply, label) {
|
||||
const res = await fetchFn()
|
||||
if (!res.ok || !res.data || !Array.isArray(res.data[key])) return
|
||||
await apply(res.data[key])
|
||||
if (label) log.info(label, { count: res.data[key].length })
|
||||
}
|
||||
|
||||
// Replay events through the dispatcher oldest-first (history/economy arrive
|
||||
// newest-first) so latest-wins state settles correctly.
|
||||
async function ingestReversed(events) {
|
||||
for (const ev of [...events].reverse()) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
}
|
||||
async function ingestEach(events) {
|
||||
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
}
|
||||
|
||||
// Pull recent events from the sidecar's own store and replay them through the
|
||||
// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE
|
||||
// make this idempotent, so overlap with what we already stored is harmless.
|
||||
async function backfill() {
|
||||
try {
|
||||
const hist = await uoLinkClient.getHistory({ limit: BACKFILL_LIMIT })
|
||||
if (hist.ok && hist.data && Array.isArray(hist.data.events)) {
|
||||
// History is newest-first; replay oldest-first so latest-wins state (e.g.
|
||||
// house.decay stage) settles correctly.
|
||||
const events = [...hist.data.events].reverse()
|
||||
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
log.info('backfilled events from /history', { count: events.length })
|
||||
}
|
||||
const eco = await uoLinkClient.getEconomy(200)
|
||||
if (eco.ok && eco.data && Array.isArray(eco.data.series)) {
|
||||
const series = [...eco.data.series].reverse()
|
||||
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
}
|
||||
await snapshot(() => uoLinkClient.getHistory({ limit: BACKFILL_LIMIT }), 'events', ingestReversed, 'backfilled events from /history')
|
||||
await snapshot(() => uoLinkClient.getEconomy(200), 'series', ingestReversed)
|
||||
|
||||
// Champ board + help-page queue have no replay stream — snapshot the
|
||||
// authoritative current state directly (the sidecar guide's advice for both),
|
||||
// reconciling our tables to it so a stale row from before a disconnect can't
|
||||
// linger. Live champ.*/page.* deltas keep them fresh thereafter.
|
||||
const champs = await uoLinkClient.getChamps()
|
||||
if (champs.ok && champs.data && Array.isArray(champs.data.spawns)) {
|
||||
await shardState.replaceChamps(champs.data.spawns)
|
||||
log.info('snapshotted champ board from /champs', { count: champs.data.spawns.length })
|
||||
}
|
||||
const pages = await uoLinkClient.getPages()
|
||||
if (pages.ok && pages.data && Array.isArray(pages.data.pages)) {
|
||||
await shardState.replacePages(pages.data.pages)
|
||||
log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length })
|
||||
}
|
||||
await snapshot(() => uoLinkClient.getChamps(), 'spawns', (s) => shardState.replaceChamps(s), 'snapshotted champ board from /champs')
|
||||
await snapshot(() => uoLinkClient.getPages(), 'pages', (p) => shardState.replacePages(p), 'snapshotted help-page queue from /pages')
|
||||
|
||||
// ── Protocol 2.0 boards ──────────────────────────────────────────────
|
||||
// Same as champs/pages: snapshot the authoritative current state and
|
||||
@@ -83,21 +84,10 @@ async function backfill() {
|
||||
// failed/absent board (e.g. no City Loyalty → empty /governors) never wipes
|
||||
// another. Governors are NOT cleared before upsert (cities are fixed and the
|
||||
// term-capture is idempotent, so a reconnect can't spawn spurious terms).
|
||||
const guilds = await uoLinkClient.getGuilds()
|
||||
if (guilds.ok && guilds.data && Array.isArray(guilds.data.guilds)) {
|
||||
await shardState.replaceGuilds(guilds.data.guilds)
|
||||
log.info('snapshotted guild board from /guilds', { count: guilds.data.guilds.length })
|
||||
}
|
||||
const governors = await uoLinkClient.getGovernors()
|
||||
if (governors.ok && governors.data && Array.isArray(governors.data.cities)) {
|
||||
await shardState.replaceGovernors(governors.data.cities)
|
||||
log.info('snapshotted governor board from /governors', { count: governors.data.cities.length })
|
||||
}
|
||||
const houses = await uoLinkClient.getHouses()
|
||||
if (houses.ok && houses.data && Array.isArray(houses.data.houses)) {
|
||||
for (const ev of houses.data.houses) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
log.info('snapshotted house registry from /houses', { count: houses.data.houses.length })
|
||||
}
|
||||
await snapshot(() => uoLinkClient.getGuilds(), 'guilds', (g) => shardState.replaceGuilds(g), 'snapshotted guild board from /guilds')
|
||||
await snapshot(() => uoLinkClient.getGovernors(), 'cities', (c) => shardState.replaceGovernors(c), 'snapshotted governor board from /governors')
|
||||
await snapshot(() => uoLinkClient.getHouses(), 'houses', ingestEach, 'snapshotted house registry from /houses')
|
||||
|
||||
const presence = await uoLinkClient.getPresence()
|
||||
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
||||
await shardState.setPresence(presence.data)
|
||||
@@ -150,66 +140,69 @@ async function connect() {
|
||||
return
|
||||
}
|
||||
|
||||
ws.on('open', async () => {
|
||||
log.info('uo-link WS connected')
|
||||
state.connected = true
|
||||
state.lastConnectedAt = Date.now()
|
||||
backoff = BACKOFF_MIN_MS
|
||||
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
||||
await backfill()
|
||||
})
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(raw.toString())
|
||||
} catch {
|
||||
log.warn('dropping non-JSON WS frame')
|
||||
return
|
||||
}
|
||||
|
||||
if (event.kind === 'ws.hello') {
|
||||
helloSeen = true
|
||||
if (event.protocol && event.protocol !== state.protocol) {
|
||||
log.error('uo-link protocol mismatch on ws.hello — closing', {
|
||||
expected: state.protocol,
|
||||
got: event.protocol,
|
||||
})
|
||||
await uoLinkConfig
|
||||
.recordStatus({ status: 'error', statusDetail: `protocol mismatch: expected ${state.protocol}, got ${event.protocol}` })
|
||||
.catch(() => {})
|
||||
running = false
|
||||
try {
|
||||
ws.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.kind === 'pong') return // sidecar heartbeat — ignore
|
||||
|
||||
state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now()
|
||||
await shardIngest.ingest(event)
|
||||
})
|
||||
|
||||
ws.on('close', async () => {
|
||||
state.connected = false
|
||||
if (running) state.reconnects += 1
|
||||
log.warn('uo-link WS closed')
|
||||
await uoLinkConfig
|
||||
.recordStatus({ status: running ? 'reconnecting' : 'disconnected', pluginConnected: false })
|
||||
.catch(() => {})
|
||||
ws = null
|
||||
scheduleReconnect()
|
||||
})
|
||||
|
||||
ws.on('open', handleOpen)
|
||||
ws.on('message', handleMessage)
|
||||
ws.on('close', handleClose)
|
||||
ws.on('error', (err) => {
|
||||
log.warn('uo-link WS error', { message: err.message })
|
||||
// 'close' fires after 'error'; reconnect is scheduled there.
|
||||
})
|
||||
}
|
||||
|
||||
// WS lifecycle handlers, split out of connect() so it stays a flat setup path.
|
||||
async function handleOpen() {
|
||||
log.info('uo-link WS connected')
|
||||
state.connected = true
|
||||
state.lastConnectedAt = Date.now()
|
||||
backoff = BACKOFF_MIN_MS
|
||||
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
||||
await backfill()
|
||||
}
|
||||
|
||||
// A ws.hello frame: mark it seen and, on a protocol mismatch, record the error
|
||||
// and close (we won't run against an incompatible sidecar).
|
||||
async function handleHello(event) {
|
||||
helloSeen = true
|
||||
if (!event.protocol || event.protocol === state.protocol) return
|
||||
log.error('uo-link protocol mismatch on ws.hello — closing', { expected: state.protocol, got: event.protocol })
|
||||
await uoLinkConfig
|
||||
.recordStatus({ status: 'error', statusDetail: `protocol mismatch: expected ${state.protocol}, got ${event.protocol}` })
|
||||
.catch(() => {})
|
||||
running = false
|
||||
try {
|
||||
ws.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(raw) {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(raw.toString())
|
||||
} catch {
|
||||
log.warn('dropping non-JSON WS frame')
|
||||
return
|
||||
}
|
||||
|
||||
if (event.kind === 'ws.hello') return handleHello(event)
|
||||
if (event.kind === 'pong') return // sidecar heartbeat — ignore
|
||||
|
||||
state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now()
|
||||
await shardIngest.ingest(event)
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
state.connected = false
|
||||
if (running) state.reconnects += 1
|
||||
log.warn('uo-link WS closed')
|
||||
await uoLinkConfig
|
||||
.recordStatus({ status: running ? 'reconnecting' : 'disconnected', pluginConnected: false })
|
||||
.catch(() => {})
|
||||
ws = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
// Begin (or restart) the WS client. Idempotent — a running client is stopped
|
||||
// first so a config save can re-point it at a new URL/token.
|
||||
async function start() {
|
||||
|
||||
Reference in New Issue
Block a user