chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s

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:
2026-07-21 04:35:39 -05:00
parent 4993470fa2
commit 12d50fd615
59 changed files with 1088 additions and 848 deletions

View File

@@ -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 }

View File

@@ -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

View File

@@ -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],

View File

@@ -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 })

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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 }) =>

View File

@@ -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,
})

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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 })

View File

@@ -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.

View File

@@ -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

View File

@@ -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() {