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

@@ -7,8 +7,8 @@ const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const registries = require('../../../modules/registries')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const newsGump = require('../../../utils/newsGump')
const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml')
const { parseJsonSetting } = require('../../../utils/settingsJson')
@@ -37,11 +37,13 @@ async function announceIfNewlyPublished(post, transition) {
// the single "newly published news" signal for the push too, so we never
// double-fire on edits or replicate the transition logic.
const jobId = await announceJobs.enqueueIfNeeded(post, transition)
// Keep the in-game Town Cryer News gump in sync with the same transition: push
// the article when it becomes published news, refresh it silently on an edit,
// and pull it when it leaves published-news. Best-effort (never throws), so a
// sidecar hiccup never breaks saving a post — same guarantee as the enqueue.
await newsGump.syncPost(post, transition)
// Tell whoever is listening that a post was saved, and what the transition
// was. Core's CMS is the only writer of posts, and a module may mirror one
// somewhere core knows nothing about — module-uo keeps UO's in-game Town Cryer
// News gump in step this way. Awaited but never throwing, so a subscriber's
// sidecar hiccup cannot break saving a post: the same guarantee the enqueue
// above gives.
await registries.dispatchPostHook('onSaved', { post, transition })
// Opt-in push tickle to news.post subscribers, on the same transition.
// Fire-and-forget + self-guarding, so a dead ntfy relay never breaks saving.
if (jobId) {
@@ -203,8 +205,11 @@ async function deletePost(req, res) {
const current = await posts.getById(id)
await posts.remove(id)
await activity.log({ req, action: 'post.delete', detail: { id } })
// If it was live in the News gump, pull it (best-effort).
if (newsGump.inGump(current)) await newsGump.removePost(id)
// And that it is gone. A subscriber decides for itself whether it was
// mirroring this one — core does not know, and asking would mean core
// holding a predicate that belongs to the subscriber (`inGump` used to live
// right here, and it was UO's question, not the CMS's).
await registries.dispatchPostHook('onDeleted', { post: current, id })
return res.json({ id })
} catch (err) {
return res.status(500).json({ message: 'Internal Server Error' })