From 2c2a5ccd5752725ba8676cfd0798136263a93ee4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 10 Aug 2026 03:44:57 -0500 Subject: [PATCH] docs(website): add the module API contract (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The normative contract between core and an installed module: the `ctx` handed to a module's entry point, the `register*` calls, the client-side registry and shared-dependency global, the schema-fragment rules, and the loader's validation and failure obligations. Every member is derived from what the UO code actually imports today, re-read against the working tree. Part 6 records four places the survey contradicted MODULE_SYSTEM.md: • OpenAPI generation is STATIC analysis (swagger-autogen parses app.js as text), unlike routeManifest.js which walks the live Express stack. A filesystem-scanning loader is invisible to it, so module routes would be silently absent from swagger-output.json. Three options, one recommended; needs a decision before Phase 2. • The client contract is much larger than §2.1 implies — the atlas pages import five core modules beyond React, so the plan needs a curated UI kit and a request primitive on window.__rg. • shardVisibility is module-owned and the atlas depends on it, so the spike carries it; two copies coexist during the spike by design. • Two counts corrected: 27 UO tables (not 25), 6 atlas routes (not 5). MODULE_SYSTEM.md gains a pointer to the contract and the corrected table count. Co-Authored-By: Claude --- README.md | 2 + website/MODULE_API.md | 564 +++++++++++++++++++++++++++++++++++++++ website/MODULE_SYSTEM.md | 21 +- 3 files changed, 581 insertions(+), 6 deletions(-) create mode 100644 website/MODULE_API.md diff --git a/README.md b/README.md index 7d9be44..9609db4 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ sidecar as a service, and hands you the values the website needs. | [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model | | [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec | | [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract | +| [MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) | Making the site game-agnostic: game logic becomes an installable module — design of record | +| [MODULE_API.md](website/MODULE_API.md) | The module ↔ core contract: `ctx`, the `register*` calls, the client registry and the loader's obligations | | [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes | | [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework | | [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree | diff --git a/website/MODULE_API.md b/website/MODULE_API.md new file mode 100644 index 0000000..23ffa35 --- /dev/null +++ b/website/MODULE_API.md @@ -0,0 +1,564 @@ +# 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. + +--- + +## 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 `