feat(modules): ctx additions and the post-hook registry (API 1.1.0)
Everything the extraction needed from core that ctx did not already offer. Additions only, so minor. ctx.activity.log, because an admin action a module performs has to land in core's one audit log or the trail has a hole exactly where a module operates the game -- a module keeping its own log would be a second place to look, which in practice means a place nobody looks. Write-only; reading the log is the admin panel's job and it spans every actor. ctx.users.getById, one function for one caller: the admin.users.detail slot router needs the user its prefix names. ctx.site.baseUrl, because a module has to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a getter, not a captured string, so it cannot go stale against the env. ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed over whole. The split is deliberate: a module states its own window and cap because it knows what its endpoints cost, and takes the plumbing from core so there is one express-rate-limit in the process and one place a breach is logged. accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit behind the same counter -- so a module's account-change route has to land IN it rather than beside it. marketLimiter was UO policy living in core's file and leaves with the route it guards. registerPostHook is the fourth registry, and the last thing binding core to the module. Core's post controller called newsGump.syncPost directly: core's CMS naming a UO file. It now publishes what it already knows and a subscriber decides what to do with it. Not folded into registerAnnounceLeg, which fires on the same transition, because a leg is a one-shot DELIVERY with retry and classification while a post hook maintains idempotent STATE, runs on delete as well as save, and refreshes silently on an edit. Also fixes a real loader defect the extraction exposed: schema table names were matched against the RAW file, so a fragment whose header says "every CREATE TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table called `carries`. module-uo's fragment hit exactly that. Both scans now read split statements, which strip comments -- the same class of bug as a boundary check failing on its own documentation. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,172 +0,0 @@
|
||||
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||
//
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
|
||||
// config/notificationStreams.js as one of the three genuinely entangled files:
|
||||
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
|
||||
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
|
||||
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
|
||||
// moves to module-uo whole in Phase 3. Nothing in core imports it except
|
||||
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
|
||||
//
|
||||
// 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')
|
||||
|
||||
const STREAMS = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
|
||||
// Derived from this file's own catalog rather than read back out of the registry:
|
||||
// the filter is about THESE streams, and a module must not be able to weaken it
|
||||
// by registering something that happens to share an 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.
|
||||
//
|
||||
// This filter, the kinds it reads and the streams it protects now all live in
|
||||
// one file and move together — the reason PR 4 dropped the contract's
|
||||
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
|
||||
// module (MODULE_API.md §2.4).
|
||||
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||
}
|
||||
|
||||
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
Reference in New Issue
Block a user