# The Module API — the contract **Status:** Phase 1 deliverable of [MODULE_SYSTEM.md](MODULE_SYSTEM.md). 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. Absent ⇒ server-only module. | | `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`, for clients (the SPA, the Android app) to feature-detect against. | ### 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.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) => Promise` | `model/settings` | `shardAtlas.model` | | `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. `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, mapEvent }) api.registerAnnounceLeg({ leg, dispatch, classify }) api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` **`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, mapEvent })`** — §1.8's push catalog. `streams` is an array of `{ id, label, description, scope }` appended to core's catalog (ids are namespaced `.` and rejected otherwise); `mapEvent(event) => streamId | null` is called by core's dispatcher for events the module's own code publishes. **`registerAnnounceLeg({ leg, dispatch, classify })`** — §1.8's news dispatcher. `leg` is a namespaced id, `dispatch(post) => Promise` delivers, `classify(post) => boolean` decides whether this leg wants the post. A leg that throws is retried by core's existing per-leg retry and never blocks another leg. **`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 the server closes, in reverse registration order, with a 5-second budget per module; exceeding it is logged and skipped rather than hanging the process. 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. ### 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`. **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. --- ## Part 3 — The client contract ### 3.1 How the chunk gets there Exactly as `MODULE_SYSTEM.md` §2.6 resolved, and Phase 1's spike is what proves it: 1. Module CI builds `client/dist/entry.js` with Vite in **library mode**, `react`, `react-dom`, `react-dom/client` and `react-router-dom` declared **external**. 2. Core serves the module directory statically at `/modules//` — same-origin, so `script-src 'self'` (`config/csp.js:49`) admits it with no nonce and no inline. 3. `utils/htmlShell.js` injects `