Files
website/server/src/utils/shardIngest.js
Claude 91c206bf76 feat(provisioning): game-account signup, admin email invites, unlink (2.0)
Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:50:49 -05:00

235 lines
8.1 KiB
JavaScript

// ── Shard event ingest dispatcher ──────────────────────────────────────────
//
// The single entry point for every event that arrives on the uo-link WebSocket
// feed (and for backfilled /history events on reconnect). It routes by kind:
// • state-changing kinds update shard_online / shard_economy / shard_houses,
// • notable kinds are appended to the append-only shard_events log,
// • every kind is fanned out to the SSE broadcaster (which decides public vs
// admin visibility).
// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged
// to shard_events — they only update state — keeping the event log lean.
//
// Dependencies are injected (defaulting to the real models) so the routing can
// be unit-tested with mocked writes.
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const broadcaster = require('./shardBroadcast')
const defaultLog = require('./logger')('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds
// (char.vitals, economy.supply, mob.login/logout, account.login.attempt,
// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled
// specially — logged only on the transition INTO IDOC.
const LOGGED_KINDS = new Set([
'vendor.sale',
'player.death',
'player.murdered',
'mob.killed',
'quest.complete',
'skill.gain',
'fame.change',
'karma.change',
'audit.set',
'audit.command',
'admin.audit',
'cheat.fastwalk',
'link.request',
'server.hello',
'server.shutdown',
'server.crashed',
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
'guild.join',
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
'account.audit',
'account.unlinked',
])
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
// can be detected and stale online state dropped. Module-level so it survives
// across events within a process; reset() is exposed for tests.
const state = { bootId: null }
function reset() {
state.bootId = null
}
// Should this event be written to the append-only log?
function shouldLog(event) {
if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC'
return LOGGED_KINDS.has(event.kind)
}
// Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, log } = deps
switch (event.kind) {
case 'server.hello': {
const incoming = event.bootId || null
if (incoming && state.bootId && incoming !== state.bootId) {
log.warn('shard restarted (bootId changed) — clearing online roster', {
from: state.bootId,
to: incoming,
})
await shardState.clearOnline()
}
if (incoming) state.bootId = incoming
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
return
}
case 'server.shutdown':
case 'server.crashed':
// Shard is going away — nobody is online anymore.
await shardState.clearOnline()
await uoLinkConfig.recordStatus({ pluginConnected: false })
return
case 'mob.login': {
const who = event.who || {}
await shardState.upsertOnline({
serial: who.serial,
name: who.name,
acct: who.acct,
webId: event.webId,
map: event.map,
x: event.x,
y: event.y,
z: event.z,
})
return
}
case 'mob.logout': {
const who = event.who || {}
if (who.serial) await shardState.setOffline(who.serial)
return
}
case 'char.vitals':
await shardState.upsertOnline({
serial: event.serial,
hits: event.hits,
hitsMax: event.hitsMax,
mana: event.mana,
manaMax: event.manaMax,
stam: event.stam,
stamMax: event.stamMax,
str: event.str,
dex: event.dex,
int: event.int,
map: event.map,
x: event.x,
y: event.y,
})
return
case 'economy.supply':
await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t })
return
case 'house.decay':
await shardState.upsertHouse({
serial: event.serial,
stage: event.to,
map: event.map,
x: event.x,
y: event.y,
z: event.z,
region: event.region,
name: event.name,
ownerSerial: event.ownerSerial,
ownerAcct: event.ownerAcct,
builtOn: event.builtOn,
lastRefreshed: event.lastRefreshed,
})
return
case 'champ.update':
await shardState.upsertChamp(event)
return
case 'champ.remove':
await shardState.removeChamp(event.serial)
return
case 'page.new':
case 'page.updated':
await shardState.upsertPage(event)
return
case 'page.closed':
await shardState.removePage(event.pageId)
return
// ── Protocol 2.0 boards ──────────────────────────────────────────────
case 'guild.update':
await shardState.upsertGuild(event)
return
case 'guild.remove':
await shardState.removeGuild(event.id)
return
case 'city.update':
// Upserts the board AND captures term history (idempotent).
await shardState.upsertGovernor(event)
return
case 'presence.online':
await shardState.setPresence(event)
return
case 'house.update':
await shardState.upsertHouseRegistry(event)
return
case 'house.remove':
await shardState.removeHouse(event.serial)
return
case 'account.unlinked':
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
// our local link mirror so attribution stops immediately.
if (event.account) await deps.shardLinks.removeByAccount(event.account)
return
// guild.join / account.audit → logged; region.enter → broadcast-only.
default:
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
// broadcasting still happen in ingest().
}
}
// 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 = {
shardEvents: deps.shardEvents || shardEventsModel,
shardState: deps.shardState || shardStateModel,
shardLinks: deps.shardLinks || shardLinksModel,
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
broadcast: deps.broadcast || broadcaster.broadcast,
log: deps.log || defaultLog,
}
if (!event || typeof event.kind !== 'string') return { logged: false, stored: false }
// ws.hello / pong are transport frames, not game events.
if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false }
const t = Number.isFinite(event.t) ? event.t : Date.now()
let stored = false
let logged = false
try {
await applyStateChange(event, d)
} catch (err) {
d.log.warn('state-change write failed', { kind: event.kind, message: err.message })
}
if (shouldLog(event)) {
logged = true
try {
stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event })
} catch (err) {
d.log.warn('event log write failed', { kind: event.kind, message: err.message })
}
}
if (!deps.fromBackfill) {
try {
d.broadcast(event)
} catch (err) {
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
}
}
return { logged, stored }
}
module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state }