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.
The entry point runs early
Section titled “The entry point runs early”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.
ctx — what you can reach
Section titled “ctx — what you can reach”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.
Three narrowings worth knowing
Section titled “Three narrowings worth knowing”Core deliberately hands you less than the underlying utility exports.
ctx.authis one function. The full facade can mint sessions; minting is core’s job. A module that needs an identity needs to read one.ctx.settingsis three functions, not the model’s 24 — most of those are registration and app-links policy that is core’s business.ctx.postsis four functions.create/update/removeare the CMS, and the CMS is not a module’s.
ctx.teams is push-only, on purpose
Section titled “ctx.teams is push-only, on purpose”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 — what you register
Section titled “api — what you register”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.
registerRoutes and the tier gate
Section titled “registerRoutes and the tier gate”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 half
Section titled “The client half”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.
The contract itself
Section titled “The contract itself”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.