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

@@ -103,6 +103,9 @@ function buildCtx(id, moduleRoot) {
const validate = require('../middleware/validate')
const noindex = require('../middleware/noindex')
const uploads = require('../router/v1/admin/imageUpload')
const activity = require('../model/activity/activity.model')
const users = require('../model/users/users.model')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */
// Narrowed on purpose (§2.3): utils/auth also re-exports signToken,
@@ -123,7 +126,26 @@ function buildCtx(id, moduleRoot) {
auth: { getUserFromRequest: auth.getUserFromRequest },
push: { publish: pushDispatch.publish },
secretBox: { encrypt: secretBox.encrypt, decrypt: secretBox.decrypt },
middleware: { requireAuth, requireRole, siteMode, validate, noindex },
middleware: {
requireAuth,
requireRole,
siteMode,
validate,
noindex,
// Rate limiting, added in API 1.1.0 as a factory plus one shared limiter.
//
// `rateLimit(options)` is core's `makeLimiter`: a module states its own
// window and cap — it knows what its endpoints cost — and takes the
// plumbing from core, so there is one express-rate-limit in the process,
// one store, and one place a breach is logged.
//
// `accountChangeLimiter` is handed over whole because it is genuinely
// shared policy: core's `/auth/me`, `/player/account` and
// `/player/appeals` are behind the same counter, and a module's
// account-change route has to land in it rather than beside it.
rateLimit: makeLimiter,
accountChangeLimiter,
},
uploads,
posts: {
listAll: posts.listAll,
@@ -131,6 +153,25 @@ function buildCtx(id, moduleRoot) {
linkAnnounceJob: posts.linkAnnounceJob,
markAnnounced: posts.markAnnounced,
},
// The admin audit log — WRITE only (§2.3, added in API 1.1.0). Core's one
// audit trail has to include the admin actions a module performs, or the
// trail has a hole exactly where a module operates the game. A module that
// kept its own log would be a second place to look, which in practice means
// a place nobody looks. `list` stays core's: reading the log is the admin
// panel's job, and it spans every actor.
activity: { log: activity.log },
// One function, for one caller: the `admin.users.detail` slot router needs
// the user its prefix names. Narrowed like `ctx.posts` — the users model
// exports creation, role changes and password handling, none of which is a
// module's business.
users: { getById: users.getById },
// Where this deployment is reachable, for a module that has to build an
// absolute link (an announcement carries one into a game window or a chat
// message, where a relative path means nothing). §2.7 forbids a module
// reading `process.env` for core configuration and this is core
// configuration, so core answers it. A getter, not a captured string: the
// value is read per call, so it cannot go stale against the env.
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
}
// A guard against accident, not against a hostile module — the boundary is
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
@@ -232,7 +273,12 @@ function coreTableNames() {
coreTables = new Set()
try {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8')
for (const m of sql.matchAll(CREATE_TABLE)) coreTables.add(m[1].toLowerCase())
// Split first, for the same reason tablesOf() does: matching the raw file
// reads CREATE TABLE out of comments, and a phantom core table makes a
// module fail with a collision against something that does not exist.
for (const statement of splitStatements(sql)) {
for (const m of statement.matchAll(CREATE_TABLE)) coreTables.add(m[1].toLowerCase())
}
} catch (err) {
log.warn('could not read core schema for the table-collision check', { message: err.message })
}
@@ -272,8 +318,9 @@ function tablesOf(dir, manifest) {
if (!manifest.schema) return new Set()
const file = path.join(dir, manifest.schema)
const sql = fs.readFileSync(file, 'utf8')
const statements = splitStatements(sql)
for (const statement of splitStatements(sql)) {
for (const statement of statements) {
const verb = (statement.match(/^\w+/) || [''])[0].toUpperCase()
if (!ALLOWED_VERBS.has(verb)) {
throw new Error(`schema fragment statement starts with "${verb}" (allowed: ${[...ALLOWED_VERBS].join(', ')})`)
@@ -285,7 +332,18 @@ function tablesOf(dir, manifest) {
}
}
return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
// Scanned over the SPLIT STATEMENTS, never the raw file. `splitStatements`
// strips `--` comments; the file does not, and a fragment that documents
// itself will say "every CREATE TABLE carries IF NOT EXISTS" in its header.
// Read raw, that yields a table called `carries`, and the module is rejected
// for a prefix violation on a table that does not exist — a message with no
// way back to the comment that caused it. module-uo's fragment hit exactly
// this on its first real load.
const tables = new Set()
for (const statement of statements) {
for (const m of statement.matchAll(CREATE_TABLE)) tables.add(m[1].toLowerCase())
}
return tables
}
function checkTableNames(id, tables) {