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:
2026-08-11 12:07:45 -05:00
committed by Claude
parent b649345484
commit f50541f374
73 changed files with 187 additions and 14020 deletions

View File

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