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>
169 lines
6.7 KiB
JavaScript
169 lines
6.7 KiB
JavaScript
// ── Push-notification stream catalog + event → stream mapping ───────────────
|
|
//
|
|
// The single source of truth for which streams a user can subscribe to, and how
|
|
// a shard event maps onto them. Two families:
|
|
// • public / opt-in — no linked game account required; delivered to every
|
|
// subscriber. Drawn ONLY from the SSE public allowlist
|
|
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
|
// can never produce a public push.
|
|
// • personal / owner-keyed — require a linked game account; delivered ONLY to
|
|
// the owning user's devices (resolved from the event's
|
|
// game account via shardLinks), never fanned out publicly.
|
|
//
|
|
// The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref });
|
|
// `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the
|
|
// real, ownership-checked content over the authenticated API. So even a leaked
|
|
// ntfy topic reveals nothing (docs/android/PLAN.md §11).
|
|
|
|
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
|
|
|
// The subscribable catalog. `news.post` is produced by the website's own posts
|
|
// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
|
|
// case; every other stream is shard-derived below.
|
|
const STREAMS = [
|
|
{
|
|
id: 'news.post',
|
|
label: 'News posts',
|
|
description: 'New news / Five-on-Friday / newsletter posts.',
|
|
personal: false,
|
|
requiresLinkedAccount: false,
|
|
},
|
|
{
|
|
id: 'server.status',
|
|
label: 'Server up / down',
|
|
description: 'The shard comes online or goes offline.',
|
|
personal: false,
|
|
requiresLinkedAccount: false,
|
|
},
|
|
{
|
|
id: 'idoc.warning',
|
|
label: 'IDOC warnings',
|
|
description: 'A house falls into its final (IDOC) decay stage.',
|
|
personal: false,
|
|
requiresLinkedAccount: false,
|
|
},
|
|
{
|
|
id: 'champ.start',
|
|
label: 'Champion spawn starts',
|
|
description: 'A champion spawn becomes active.',
|
|
personal: false,
|
|
requiresLinkedAccount: false,
|
|
},
|
|
{
|
|
id: 'governor.election',
|
|
label: 'Governor elections',
|
|
description: 'A town elects a new governor.',
|
|
personal: false,
|
|
requiresLinkedAccount: false,
|
|
},
|
|
{
|
|
id: 'vendor.sale',
|
|
label: 'Your vendor sold an item',
|
|
description: 'One of your player vendors made a sale.',
|
|
personal: true,
|
|
requiresLinkedAccount: true,
|
|
},
|
|
{
|
|
id: 'house.idoc',
|
|
label: 'Your house entered IDOC',
|
|
description: 'One of your houses fell into its final decay stage.',
|
|
personal: true,
|
|
requiresLinkedAccount: true,
|
|
},
|
|
{
|
|
id: 'account.login',
|
|
label: 'A login to your account',
|
|
description: 'An authentication attempt against your game account.',
|
|
personal: true,
|
|
requiresLinkedAccount: true,
|
|
},
|
|
]
|
|
|
|
const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
|
|
const isValidStream = (id) => STREAM_IDS.has(id)
|
|
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
|
|
|
// Per-process transition state so full-state upserts (champ.update / city.update
|
|
// are upserts, not discrete "started"/"elected" events — see docs/link
|
|
// PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests
|
|
// pass a fresh tracker; a module-level default backs the live dispatcher.
|
|
function createTracker() {
|
|
return { champActive: new Map(), cityGovernor: new Map() }
|
|
}
|
|
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`.
|
|
function mapShardEvent(event, tracker = defaultTracker) {
|
|
if (!event || typeof event.kind !== 'string') return []
|
|
const kind = event.kind
|
|
const out = []
|
|
|
|
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
|
|
// they are exempt from the public allowlist (that is the whole point of the
|
|
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
|
// even if a future mapping case is added carelessly.
|
|
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
|
}
|
|
|
|
module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
|