# The Module API — the contract **Status:** Phase 1 deliverable of [MODULE_SYSTEM.md](MODULE_SYSTEM.md), **validated by the atlas spike** — Part 7 records what the spike proved, what it changed in this contract, and the three artifacts in it that are not design. This document is the normative contract between the core website and an installed module. `MODULE_SYSTEM.md` decides *what* the module system is; this decides *exactly what a module may call, what it must provide, and what core promises not to break*. Everything below is derived from what the UO code actually does today, re-read against the working tree on 2026-08-10. Where the survey contradicted `MODULE_SYSTEM.md`, the contradiction is recorded in Part 6 rather than quietly resolved — four of them, one of which (OpenAPI, §6.1) needs a decision before Phase 2 starts. **The one rule everything else serves:** a module reaches core *only* through the members named in this document. Zero `require`/`import` from a module to a core file, enforced in CI (§5.1). A core refactor that leaves this contract intact cannot break a module; anything a module needs that is not here extends the contract first, in this file, before the module is written against it. --- ## Part 1 — Versioning ### 1.1 `MODULE_API_VERSION` Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js const MODULE_API_VERSION = '1.0.0' ``` Every `module.json` declares a `coreApi` semver **range**. The loader checks it at boot, before it requires a line of module code, and a mismatch fails that module loudly into `startup_failed` (§4.4) with the two versions in the reason. It never silently proceeds. | Change | Bump | | --- | --- | | A member is added to `ctx`, or a new `register*` call appears | minor | | A member is removed or its signature changes | major | | Behaviour of an existing member changes without a signature change | major | | A core-internal refactor behind an unchanged member | none | This is a **separate number from `PROTOCOL_VERSION`**, which versions the shard wire and has nothing to say about a website module. It is also separate from the module's own version. ### 1.2 What is *not* contract Core's internal file layout, table names, middleware ordering, the `api` client object's shape, and every component under `client/src/components/` except the ones named in §3.4. A module that reaches any of these is out of contract even if it happens to work. --- ## Part 2 — The server contract ### 2.1 `module.json` Read synchronously by the loader from `modules//module.json`. Unknown top-level keys are rejected rather than ignored, so a typo is a loud failure and not a silently-inert setting. ```json { "id": "uo", "name": "Ultima Online", "version": "1.0.0", "coreApi": "^1.0.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", "purge": "server/db/purge.sql", "mounts": { "public": ["/shard", "/atlas"], "admin": ["/shard", "/uo-link"], "player": ["/shard"] }, "extensions": ["admin.users.detail"], "capabilities": ["shard", "atlas", "market"] } ``` | Key | Required | Meaning | | --- | --- | --- | | `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, the `window.__rg` registry key. Must equal the directory it was read from. | | `name` | yes | Human label for the admin Modules screen. | | `version` | yes | Semver. Recorded in `installed_modules`; shown on failure. | | `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). | | `server` | no | Entry point, relative to the module root. Absent ⇒ client-only module. | | `client.entry` | no | Prebuilt ESM chunk, relative to the module root, and **in a subdirectory** — the directory it sits in is what gets served (§3.1). Absent ⇒ server-only module; present-but-empty is rejected, since it claims a client half and delivers none. | | `schema` | no | Idempotent SQL fragment (§2.6). | | `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. | | `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. | | `extensions` | no | Core extension slots this module mounts into (§2.4). | | `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules` (§2.9), for clients (the SPA, the Android app) to feature-detect against. Published only while the module is `started`. | ### 2.2 The entry point `server/index.js` exports a single function. It is called once, synchronously, during `app.js` require — **not** after the database is up. ```js module.exports = function register(ctx, api) { /* … */ } ``` It must not `await`, must not touch the database, and must not throw for a reason that a retry would fix. Everything that needs a live database belongs in `onBoot` (§2.5). This constraint is not stylistic: `scripts/routeManifest.js` and `swagger/swagger.js` both require `app.js` with the pool pointed at a dead port, and a module that queried at registration time would hang both. ### 2.3 `ctx` — what core hands the module Every member below exists because a UO file uses it today. Nothing is speculative, and nothing that module-uo does not need is on the list. | Member | Signature | Backed by | First real caller | | --- | --- | --- | --- | | `ctx.express` | the `express` namespace | core's `node_modules` | every module router (§7.2) | | `ctx.validator` | the `express-validator` namespace | core's `node_modules` | `atlas.router.js` | | `ctx.db.query` | `(sql, params?) => Promise` | `utils/db` | every `*.db.js` | | `ctx.db.pool` | mariadb pool | `utils/db` | `shardAtlas.db.js` (streamed import) | | `ctx.log` | `(namespace) => { error, warn, info, debug }`, each `(msg, meta?)` | `utils/logger` | all nine UO utils | | `ctx.settings.get` | `(key) => Promise` | `model/settings` | `shardAtlas.model` | | `ctx.settings.set` | `(key, value, updatedBy?) => Promise` | `model/settings` | `shardAtlas.model:60` | | `ctx.settings.getInstanceName` | `() => Promise` | `model/settings` | `shardIngest.js:84` | | `ctx.auth.getUserFromRequest` | `(req) => { id, username, role } \| null` | `utils/auth` | `shardVisibility.js` | | `ctx.push.publish` | `(streamId, { ref?, ownerUserId? }) => Promise` | `utils/pushDispatch:92` | `shardIngest.js:22` | | `ctx.secretBox` | `{ encrypt(s), decrypt(s) }` | `utils/secretBox` | `uoLinkConfig.model` | | `ctx.middleware` | `{ requireAuth, requireRole, siteMode, validate, noindex }` | `auth/session.middleware`, `middleware/*` | every UO router | | `ctx.uploads` | `{ upload, UPLOAD_DIR, MIME_EXT }` | `admin/imageUpload.js` | atlas art import | | `ctx.posts` | `{ listAll, getById, linkAnnounceJob, markAnnounced }` | `model/posts` | `newsGump.js:108`, `announceWorker.js:58` | | `ctx.paths.moduleRoot` | absolute path to `modules//` | loader | atlas art, cliloc files | | `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks | Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate: - **`ctx.auth` is one function, not `utils/auth`.** The facade also re-exports `signToken`, `setAuthCookie` and the TOTP challenge primitives. Minting sessions is core's job; a module that needs an identity needs to *read* one. - **`ctx.settings` is three functions, not the model.** The model exports 24 names, most of them registration/game-signup/app-links policy that is core's business. - **`ctx.posts` is four functions.** `create`/`update`/`remove` are the CMS, not a module's. And one addition the spike forced: **`ctx.express` and `ctx.validator`**. A module lives at `/modules//`, outside `server/`, so Node's resolver never reaches `server/node_modules` and `require('express')` from a module simply fails — which is how this was found. Even where it resolved, a second express in the process is a second `Router` prototype. Core owns one express, as it owns one React (§7.2). `ctx` is frozen (`Object.freeze`, one level deep) before it is handed over. That is a guard against accident, not against a hostile module — per `MODULE_SYSTEM.md` §2.2 the boundary is organisational, not a security boundary. ### 2.4 `api` — what the module registers The second argument. Every call is synchronous, idempotent-free (calling twice is an error), and validated at once rather than at first use. ```js api.registerRoutes({ public: {...}, admin: {...}, player: {...} }) api.registerExtension(slot, router) api.registerNotificationStreams(streams) api.registerAnnounceLeg({ leg, label, dispatch, classify }) api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` **Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's shape is checked at the call, so a malformed one throws with the registrant's own stack; whether the name is *taken* can only be answered once the batch is complete, and is checked when the loader commits it in its second pass. The consequence is the one that matters: a module that registers two streams and then throws — or fails `checkDeclared` after `register()` returns — has left nothing behind. A half-registered catalog would be worse than a missing one, because it is a subscribable stream nothing will ever publish to. This is the registry-side twin of §4.3's second-pass mount rule, and both exist for the same reason. **`registerRoutes(mounts)`** — one `express.Router()` per prefix per tier: ```js api.registerRoutes({ public: { '/shard': shardRouter, '/atlas': atlasRouter }, admin: { '/shard': adminShardRouter, '/uo-link': uoLinkRouter }, player: { '/shard': playerShardRouter }, }) ``` The keys must match `module.json`'s `mounts` exactly. Prefixes are validated `^/[a-z0-9][a-z0-9-]*$` — one segment, no nesting, no parameters — and rejected on collision with core's own mount table or with another module's, at registration time. The router is mounted *inside* the tier, so it structurally cannot reach above its prefix. **The tier gate is already applied.** A router registered under `admin` sits behind `noindex, isLoggedIn, requireRole('admin','editor','moderator')` from `router/v1/admin/index.js`; under `player`, behind `noindex, requireAuth`; under `public`, behind nothing, by design. A module adds per-route gates on top of that and never re-implements the tier gate. **`registerExtension(slot, router)`** — the §1.9 case: module routes hanging off a *core* resource. Only core may declare a slot; a module may only fill one. Exactly one slot exists in v1: | Slot | Mounted at | Declared by | | --- | --- | --- | | `admin.users.detail` | `/api/v1/admin/users/:id` | `router/v1/admin/users.router.js` | The router receives `req.params.id` from the parent (`mergeParams: true`). Two modules filling the same slot is a collision and is rejected; core's own routes on the resource always win a path conflict. **`registerNotificationStreams(streams)`** — §1.8's push catalog. An array of `{ id, label, description, personal, requiresLinkedAccount }` appended to core's catalog. Ids are namespaced `.` and rejected otherwise, save for the seven grandfathered ones in §6.4. Two amendments this signature carries, both settled 2026-08-10 with PR 4: - **`mapEvent` is gone.** The earlier signature took `{ streams, mapEvent }`, with core's dispatcher calling `mapEvent(event) => streamId`. That was a leftover from before §1.8's push inversion was settled: the module owns `fromShardEvent` outright and calls `ctx.push.publish(streamId, …)` with an id it has already resolved, so core never needs a second way to get there. What core wants from a module here is the catalog — for the subscribe endpoint, for validating a subscription write, and for the personal/linked-account gate. It follows that the public-safety filter (a sensitive event kind can never produce a *public* push) is module-internal; that is the right home, because the kinds, the streams and the filter are then one file that moves together, rather than a rule in core about data only the module defines. - **Two booleans, not one `scope`.** The entry shape above is the response body of `GET /auth/me/notifications/streams`, which a shipped Android client already reads (`NotificationsDto.kt`). `scope` was never the wire shape. **`registerAnnounceLeg({ leg, label, dispatch, classify })`** — §1.8's news dispatcher. `leg` is a namespaced id, `label` is what the admin panel shows, `dispatch(post) => Promise` delivers, and `classify(result) => { outcome, error }` maps the client's result to `done` / `retry` / `terminal`. A leg that throws is caught, classified as a retry, and never blocks another leg. `label` is an addition: the panel used to hold a client-side `{ towncrier, discord }` label table, which would have left a module's leg rendering as a bare id. It comes from the registration so a module needs no client change. **Legs are rows, not columns.** `announce_jobs` carried a `towncrier_*` and a `discord_*` column group until PR 4; a module cannot `ALTER` a core table, so a registered leg had nowhere to live. The per-leg state moved to `announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)` and `leg` is a stored value. The parent `status` rollup is over *all* the job's legs — done when every leg delivered, failed when every leg gave up, partial in between; and `done` when a job has no legs at all, since nothing is left to deliver. **`onBoot(fn)` / `onShutdown(fn)`** — §2.5. ### 2.5 Lifecycle ``` require(module) → register(ctx, api) → [routes mounted, app.js require returns] ↓ (server.js, after ensureSchema + seed) onBoot(ctx) → started ↓ (SIGINT/SIGTERM) onShutdown() ``` `onBoot` is where the eight `server.js` UO call sites go (`MODULE_SYSTEM.md` §1.7): the atlas and cliloc refreshes, the market display-name backfill, `uoLinkSocket.start()`, the sidecar probe. It runs **after** `ensureSchema()` (so the module's own tables exist) and after `seedDefaults()`, and **before** the HTTP listener binds — a module that must not serve traffic before it has warmed its cache gets that for free. `onShutdown` runs before anything core owns is closed — the database pool, the push dispatcher and the SSE fan-out are all still open, because a module's `onShutdown` is the only chance it gets to flush through them. Reverse registration order, with a 5-second budget per module; exceeding it is logged and the hook abandoned rather than hanging the process. Abandoned, not cancelled: nothing can stop a promise that is still running, but the process is exiting anyway and the alternative is a host where `systemctl stop` waits for SIGKILL. **`onBoot` has no budget, deliberately.** Shutdown races the process being killed; boot does not. A slow `onBoot` delays the listener binding, which is the guarantee two paragraphs up rather than a problem to be timed out, and core's own boot steps are awaited exactly the same way. Both hooks are optional, and both are individually try/caught. An `onBoot` that throws marks that module `startup_failed` (§4.4) and the site still comes up — its routes stay mounted but its dispatch guard rejects them with 503, because a module that failed to warm up serving half-initialised data is worse than a module that says it is down. A module with no `onBoot` at all still reaches `started`: having nothing to warm up is not the same as never having started, and the row has to agree with the guard about whether the module is serving. A module whose `onBoot` threw gets **no** `onShutdown` — it is part-way through a warm-up it never finished, and handing it a half-built world to tear down is worse than not closing cleanly. `onBoot` receives the same frozen `ctx` object `register()` was given, not a second one built to look like it. **What a boot does to `installed_modules`** (`MODULE_SYSTEM.md` §2.4). The dispatch is the second half of a reconcile, and the order of its four steps is the design: 1. Clear the last boot's outcomes, so what is on display afterwards is what *this* boot did. `disabled` rows are left alone — that is an operator decision, not an outcome. 2. Write a row for every module found on the volume, with null provenance if it has none. A directory placed on the volume by hand is a supported install (§2.5 of the design of record) and without a row it could be neither disabled nor reported. 3. Mark any row whose directory is **not** on the volume `startup_failed` (stage `require`). Step 1 has just reset it to `enabled`, and a row claiming to be enabled for a module that is not there is the one state that is simply untrue. A plain uninstall leaves `disabled`, which step 1 never touches, so this catches only a directory deleted by hand. 4. Write down the outcome each module already carries — disabled by the operator, or failed during load or schema replay, both of which happen before the database is reachable — and only then dispatch `onBoot`. **The operator's switch wins over everything, including a failure.** A module whose row says `disabled` is guarded (§4.5), is not booted, and does **not** have its failure re-recorded: overwriting a deliberate `disabled` with an outcome would silently switch it back on at the next boot. **A bookkeeping failure is not a boot failure.** Every database write in the reconcile is individually caught. A row that will not update is bad — the admin panel shows the wrong thing — but it is strictly less bad than a site that will not start, and it must not stop the modules behind it from booting. Dispatch and reconcile live in `server/src/modules/lifecycle.js`, not in the loader: `routeManifest.js` and `swagger.js` both require `app.js` against a dead pool (§4.1), so the loader may not reach the database. The two halves meet at exactly one place — `loader.setState()` — so the in-memory record the dispatch guard reads and the row the admin panel reads are moved together and cannot disagree. ### 2.6 Schema fragments `schema` is an idempotent `.sql` file replayed by the same `ensureSchema()` that replays core's, immediately after it, statement by statement, split the same way. It is subject to the same rules core's file already follows: `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, no `--` inside a string literal, no `DROP`. "Split the same way" is shared code, not a shared description: `utils/sqlStatements.js` holds the splitter and both callers use it. It is its own file rather than an export of `utils/db.js` because the loader validates fragments at require time and must not pull the mariadb pool into `app.js`'s require chain to do it. **The rules above are enforced at LOAD time, not at replay time** (PR 3). Everything §2.6 states about the SQL is knowable by reading the file, so a fragment that breaks a rule costs the module its mount entirely (§4.4's left-hand column) rather than mounting and then 503ing with tables half created. What is left for the replay is the class of failure only the database can report — an unknown column type, a bad foreign key — and those are post-mount and answer 503. **The check is a leading-verb allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Those are the four core's own `schema.sql` uses. It is an allowlist rather than the `DROP` denylist this section words it as because a fragment is **replayed on every boot**: `TRUNCATE` and `DELETE` would empty a table at every restart, `RENAME` would fail at the second one, and `GRANT`/`SET`/`USE` are core's business. A denylist only ever bans what somebody thought of. It is a leading-verb check and claims no more: `ALTER TABLE x DROP COLUMN y` passes it, and catching that needs a SQL parser — a large dependency for a rule whose job is stopping the obvious foot-gun early. A `CREATE TABLE` missing `IF NOT EXISTS` is rejected on the same grounds: it succeeds exactly once and fails every boot after, presenting to an operator as a module that broke on restart. **The replay is outside `ensureSchema()`'s retry loop.** Core's schema is retried ten times while the database comes up; a fragment that throws is one module's failure, not a signal the database is not ready, and retrying core's whole schema over one module's bad SQL would turn a 503'd module into a two-minute boot. Partial application is accepted rather than compensated for — MariaDB self-commits each DDL statement, so no transaction could roll back the tables created before the failing one, and the idempotence rule is what makes re-running a corrected fragment safe. **One caller replays nothing, deliberately.** `db/seed.js` (`npm run seed`) calls `ensureSchema()` standalone without requiring `app.js`, so no scan has happened and `fragments()`'s §7.6 throw would break seeding outright. The replay asks `isLoaded()` and logs the skip. That is the only sanctioned use of that predicate: everywhere else, reading the module list before `load()` still throws, because a booting server quietly getting no module tables is precisely what §7.6 exists to prevent. **Table names are namespaced and collision-checked.** New tables must be prefixed `_`. The loader extracts every `CREATE TABLE IF NOT EXISTS ` from the fragment and rejects the module if a name collides with a core table or with another module's — a wrong `DROP`-free fragment can still silently adopt someone else's table otherwise. **module-uo is grandfathered.** Its 27 tables are named `shard_*` (26) and `uo_link_config` (1), and renaming them is a data migration this workstream explicitly does not do (`MODULE_SYSTEM.md` §1.6 puts the count at 25; the working tree says 27 — see §6.4). They are registered in the loader as an explicit legacy allowlist keyed to `id: "uo"`, so the prefix rule holds for every module written after this one. `purge` is the destructive counterpart, run **only** by the explicit admin purge action, never by uninstall. Required whenever `schema` is present: a module that can create tables and cannot drop them leaves an operator with orphaned data and no supported way to remove it. ### 2.7 What a module must not do - `require` anything outside its own directory except node built-ins and its own `dependencies`. - Mutate `ctx`, `req.user`, or any object core handed it. - Register an Express error handler, or any middleware at the app level. - Read `process.env` for core configuration. Its own config is a `settings` key or its own table. - Call `process.exit`, install signal handlers, or start a listener. - Write outside `ctx.paths.moduleRoot` and the upload directory. ### 2.8 The OpenAPI fragment Every module that registers routes ships `swagger-fragment.json` in its bundle root. Core merges the fragments of started modules into `/api/docs.json`; the full reasoning and the collision rules are §6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered route has no path in the fragment, and core wins every key collision. ### 2.9 What core publishes about a module `GET /api/v1/public/modules` — anonymous, database-free, never site-mode gated. ```json { "modules": [ { "id": "uo", "name": "Ultima Online", "version": "1.0.0", "capabilities": ["shard", "atlas", "market"] } ] } ``` Four fields, in the loader's scan order (§4.2). What is *not* there is the design: - **Only `started` modules appear.** The endpoint answers what this backend is serving. A module that is `disabled` or `startup_failed` is **absent**, which is the same answer §4.4 already gives for its routes and its nav — a client renders a site without that capability rather than one advertising a capability that 503s. `installed` and `registered` are likewise absent: neither is serving yet. - **No `state`, no `failure_stage`, no `failure_reason`.** Where a module broke and how far it got is operator detail for the admin Modules screen. An anonymous visitor is not told that something is broken, and the message — which is an exception string from inside core — never leaves the server. - **No `client` chunk URL.** `utils/htmlShell.js` injects a `