Skip to content

The module API

Your server entry point exports one function:

module.exports = function register(ctx, api) { /* … */ }

ctx is what core lends you. api is what you register with it. Everything crossing the module boundary goes through one of the two.

The contract version is MODULE_API_VERSION, currently 1.6.0, and your manifest’s coreApi range is checked against it before your code is required.

register() is called once, synchronously, during core’s require phase — not after the database is up.

It must not await, must not touch the database, and must not throw for a reason a retry would fix. Everything needing a live database belongs in onBoot.

Every member exists because a real module needed it. The surface is grown from demonstrated need, never speculation.

Member What it gives you
ctx.express, ctx.validator Core’s own express and express-validator namespaces
ctx.db.query, ctx.db.pool Parameterised SQL, and the pool for streaming work
ctx.log(namespace) error / warn / info / debug, each (msg, meta?)
ctx.settings get, set, getInstanceName
ctx.auth.getUserFromRequest(req) { id, username, role } or null
ctx.push.publish Notification fan-out
ctx.secretBox encrypt / decrypt for secrets at rest
ctx.middleware requireAuth, requireRole, siteMode, validate, noindex, rateLimit, accountChangeLimiter
ctx.uploads upload, UPLOAD_DIR, MIME_EXT
ctx.posts listAll, getById, linkAnnounceJob, markAnnounced
ctx.paths.moduleRoot Absolute path to your own directory
ctx.activity.log The admin audit trail
ctx.users.getById Read a user
ctx.site.baseUrl Absolute base URL, no trailing slash
ctx.moduleId Your id, from the manifest
ctx.teams publish, reconcile, activity.push — see below

ctx is frozen one level deep before you get it. That is a guard against accident, not against a hostile module — the boundary is organisational, not a security boundary.

Core deliberately hands you less than the underlying utility exports.

  • ctx.auth is one function. The full facade can mint sessions; minting is core’s job. A module that needs an identity needs to read one.
  • ctx.settings is three functions, not the model’s 24 — most of those are registration and app-links policy that is core’s business.
  • ctx.posts is four functions. create / update / remove are the CMS, and the CMS is not a module’s.

There is no reader. A module answers questions about Teams; it does not ask them. Every Team table is core-internal, and a getTeamRoster would be core offering to read back the module’s own answer — which the module already holds.

All three members are fire-and-forget and never reject, because they are called from inside game-event handlers and a storage problem of core’s must not become your control flow.

See Teams architecture for the whole shape.

api.registerRoutes({ public: {…}, admin: {…}, player: {…} })
api.registerExtension(slot, router)
api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
api.registerSlashCommands([{ name, description, options, access, handler }])
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})

Every call is synchronous, and calling one twice is an error rather than a last-one-wins overwrite.

Everything stages; nothing commits until you are known good

Section titled “Everything stages; nothing commits until you are known good”

A claim’s shape is checked at the call, so a malformed one throws with your own stack. Whether a name is taken can only be answered once the whole batch is in, and is checked when the loader commits.

The consequence is the one that matters: a module that registers two streams and then throws has left nothing behind. A half-registered catalog would be worse than a missing one — it is a subscribable stream that nothing will ever publish to.

One express.Router() per prefix per tier. The keys must match module.json’s mounts exactly, and prefixes are one segment — no nesting, no parameters.

The tier gate is already applied. A router registered under admin sits behind noindex, isLoggedIn, requireRole('admin','editor','moderator'); under player, behind noindex, requireAuth; under public, behind nothing, by design.

Add per-route gates on top of that. Never re-implement the tier gate — a module that rolls its own is a module whose access rules drift from core’s.

Your router is mounted inside the tier, so it structurally cannot reach above its prefix.

The client contract is its own thing. Core populates a global before it renders, and freezes it afterwards:

window.__rg = {
version, // MODULE_API_VERSION — the same number as the server's
react, // the React namespace
reactDom, // react-dom/client
router, // react-router-dom namespace
jsxRuntime, // react/jsx-runtime
registry, // routes, nav, feature providers, slots
ui, // the shared component kit
api, // the request primitive
}

Your chunk declares react, react-dom and react-router-dom as externals resolving to that global — a global rather than an import map precisely because an import map must be inline and script-src 'self' forbids inline script.

jsxRuntime is not decoration. Your bundler compiles every .jsx file to imports from react/jsx-runtime under the modern automatic runtime, and those must resolve to core’s React like everything else. Without it on the global you would have to build with jsxRuntime: 'classic'; with it, you use the default your tooling already assumes.

version is there so your entry can check it. A module entry compares window.__rg.version against its own coreApi range and refuses to register on a mismatch, logging once — the client-side twin of the boot-time check.

You register routes, navigation and feature providers through registry. See Building a module.

MODULE_API.md is normative and complete — Part 2 for the server contract, Part 3 for the client, Part 4 for the loader’s obligations and Part 5 for how they are enforced. This page is a map of it, not a substitute.