# 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. 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.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, 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 `