docs(website): the module API contract, validated by the atlas spike (phase 1) #124
@@ -1,6 +1,8 @@
|
||||
# The Module API — the contract
|
||||
|
||||
**Status:** Phase 1 deliverable of [MODULE_SYSTEM.md](MODULE_SYSTEM.md). This document is the
|
||||
**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*.
|
||||
@@ -111,11 +113,13 @@ 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<rows>` | `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<string\|null>` | `model/settings` | `shardAtlas.model` |
|
||||
| `ctx.settings.set` | `(key, value) => Promise<void>` | `model/settings` | `shardAtlas.model` |
|
||||
| `ctx.settings.set` | `(key, value, updatedBy?) => Promise<void>` | `model/settings` | `shardAtlas.model:60` |
|
||||
| `ctx.settings.getInstanceName` | `() => Promise<string>` | `model/settings` | `shardIngest.js:84` |
|
||||
| `ctx.auth.getUserFromRequest` | `(req) => { id, username, role } \| null` | `utils/auth` | `shardVisibility.js` |
|
||||
| `ctx.push.publish` | `(streamId, { ref?, ownerUserId? }) => Promise<void>` | `utils/pushDispatch:92` | `shardIngest.js:22` |
|
||||
@@ -135,6 +139,12 @@ Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
|
||||
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
|
||||
`<repo>/modules/<id>/`, 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.
|
||||
@@ -289,12 +299,18 @@ window.__rg = {
|
||||
react, // the React namespace
|
||||
reactDom, // react-dom/client
|
||||
router, // react-router-dom namespace
|
||||
jsxRuntime, // react/jsx-runtime — see below
|
||||
registry, // §3.3
|
||||
ui, // §3.4
|
||||
api, // §3.5
|
||||
}
|
||||
```
|
||||
|
||||
`jsxRuntime` is not decoration. A module's bundler compiles every `.jsx` file to imports from
|
||||
`react/jsx-runtime` under the modern automatic runtime, and those have to resolve to *core's* React
|
||||
like every other import. Without it on the global a module would have to build with
|
||||
`jsxRuntime: 'classic'`; with it, the module uses the default its tooling already assumes.
|
||||
|
||||
A module entry checks `window.__rg.version` against its own `coreApi` range and refuses to register
|
||||
on a mismatch, logging once — the client-side twin of §1.1, and the reason `version` is here at all.
|
||||
|
||||
@@ -588,3 +604,96 @@ files. Two consequences:
|
||||
|
||||
Neither changes a decision; both are corrected here rather than left to be tripped over when the
|
||||
extraction is counted against the plan.
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — What the spike proved
|
||||
|
||||
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately
|
||||
never merged** — it is the evidence, not the implementation. Phase 2 rebuilds the loader properly.
|
||||
|
||||
### 7.1 The exit criteria
|
||||
|
||||
| Criterion | Result |
|
||||
| --- | --- |
|
||||
| No internal-file imports from the module into core | **pass** — the module's only non-builtin requires are `ctx.express` / `ctx.validator`; the built client chunk contains **zero** bare import specifiers |
|
||||
| `npm run routes:manifest` produces a zero-line diff | **pass** — `routes.manifest.json` *and* `routes.guards.json` are byte-identical with the six atlas routes now served by the module |
|
||||
| The chunk loads under the enforced CSP | **pass** — `/uo/atlas` and `/uo/atlas/:slug` render from `/modules/uo/entry.js` under `script-src 'self'`, with **zero** violation reports at `/api/csp-report` and a clean console |
|
||||
| Everything still passes | **pass** — 729 core tests, 81 module tests |
|
||||
|
||||
Also verified end to end against the real database: the schema fragment replayed after core's
|
||||
(`schema ensured for module "uo"`), `onBoot` ran the atlas refresh, the module reached `started`, and
|
||||
the six API URLs answered 200 unchanged at `/api/v1/public/atlas/*`.
|
||||
|
||||
### 7.2 One express, one React — the same rule, twice
|
||||
|
||||
The single biggest thing the spike changed. `MODULE_SYSTEM.md` §2.6 got the client half right — one
|
||||
React, shared via a global — and said nothing about the server, where the identical problem exists
|
||||
and bites harder:
|
||||
|
||||
- A module lives at `<repo>/modules/<id>/`. Node's resolver walks *up* from there and never reaches
|
||||
`server/node_modules`, so `require('express')` inside a module **fails outright**. This was the
|
||||
first error the spike hit.
|
||||
- Installing express into the module would fix resolution and break something worse: two `Router`
|
||||
prototypes, two sets of `instanceof` checks — and it would mean the operator running `npm install`
|
||||
in a module directory, which is the build the whole plan exists to avoid.
|
||||
|
||||
Hence `ctx.express` and `ctx.validator`. The rule generalises: **anything shared between core and a
|
||||
module is owned by core and handed over — never resolved by the module.** On the client that is
|
||||
`react`, `react-dom/client`, `react-router-dom` and `react/jsx-runtime`; on the server it is
|
||||
`express` and `express-validator`.
|
||||
|
||||
Two mechanical traps inside that, both cheap once known and both silent otherwise:
|
||||
|
||||
- **Vite's object-form `resolve.alias` does PREFIX matching.** A `react` key also rewrites
|
||||
`react/jsx-runtime` into `src/shim/react.js/jsx-runtime`, a path that cannot exist. Use the array
|
||||
form with anchored regexes (`/^react$/`).
|
||||
- **`external` alone is not enough for an ESM library build.** Rollup then emits bare
|
||||
`import 'react'`, which the browser cannot resolve without an import map — and CSP forbids the
|
||||
inline `<script type="importmap">` that would supply one. Each shared dependency needs a two-line
|
||||
alias shim that re-exports from `window.__rg`. `output.globals` does not help: it applies to
|
||||
iife/umd output only.
|
||||
|
||||
### 7.3 §6.1 confirmed empirically, not just predicted
|
||||
|
||||
Regenerating the OpenAPI spec after the move deleted **361 lines** — all six atlas paths — from
|
||||
`swagger-output.json`, with `Swagger-autogen: Success` and no warning of any kind. The route manifest
|
||||
kept all six in the same run. That is the static-analysis-versus-runtime split of §6.1 happening for
|
||||
real, and it is exactly the silent failure the fragment merge exists to prevent. Core's committed
|
||||
spec is correct as regenerated — it describes core's own routes — and the six paths come back via
|
||||
module-uo's fragment when Phase 2 item 2 lands.
|
||||
|
||||
### 7.4 The loader's failure guarantees are tested, not asserted
|
||||
|
||||
`server/test/moduleLoader.test.js` — 17 tests over the paths nobody exercises by hand: an entry point
|
||||
that throws, a `coreApi` mismatch, an unknown manifest key, an id that disagrees with its directory,
|
||||
two modules claiming one prefix, a module claiming a core prefix, registering an undeclared prefix
|
||||
and declaring an unregistered one, a fragment naming a core table, an unprefixed table, a schema with
|
||||
no purge, an `onBoot` that throws, an `onShutdown` that hangs and one that throws, a double
|
||||
registration, and a probe asserting `ctx` exposes exactly the documented surface and is frozen.
|
||||
|
||||
The property under test throughout is the same: **the failing module fails alone.**
|
||||
|
||||
### 7.5 Spike artifacts that are NOT design
|
||||
|
||||
Three things in the branch are consequences of stopping at six routes, and Phase 3 removes all three.
|
||||
They are recorded so nobody reads them as intended shape:
|
||||
|
||||
1. **Core reaches into the module twice** — `admin/shardAtlas.controller.js` and
|
||||
`test/atlasController.test.js` require the module's model directly. The five admin atlas routes
|
||||
live at `/admin/shard/atlas/*`, inside the `/shard` prefix core still owns, so the module cannot
|
||||
take them without colliding or moving a URL. Phase 3 moves the whole `/shard` admin prefix at once
|
||||
and the imports go with it.
|
||||
2. **Two copies of `shardVisibility`** — the module's (as `utils/visibility.js`) and core's, for the
|
||||
shard routes not yet extracted. Two five-second caches over the same table; functionally
|
||||
identical. Predicted in §6.3, observed exactly as described.
|
||||
3. **The module has no `swagger-fragment.json`** — §6.1a's obligation needs core's merge helper on
|
||||
the other side of it, which is Phase 2.
|
||||
|
||||
### 7.6 A finding for Phase 2's loader
|
||||
|
||||
`scan()` is lazy — requiring the loader does not run it. That is deliberate (app.js decides when
|
||||
modules are discovered) but it is a sharp edge: a caller that requires the loader and reads nothing
|
||||
gets an empty, *silent* module list. It cost one confusing test failure during the spike. Phase 2
|
||||
should either make the trigger explicit in the name or scan at require time and let app.js order the
|
||||
require.
|
||||
|
||||
@@ -392,6 +392,13 @@ hook. The spike must *also* prove the §2.6 chunk load end to end, since that is
|
||||
decision in the plan. Exit criteria: no internal-file imports, `npm run routes:manifest` produces a
|
||||
zero-line diff, and the chunk loads under the enforced CSP.
|
||||
|
||||
**Phase 1 is complete.** The spike ran on `website` branch `spike/module-atlas` (cut from `edge`,
|
||||
never merged) and **met all three exit criteria** — see [`MODULE_API.md`](MODULE_API.md) Part 7. §2.6
|
||||
survives intact: the prebuilt chunk loads and renders under `script-src 'self'` with zero violation
|
||||
reports. The one thing it changed is that §2.6's one-React rule turns out to have a server-side twin
|
||||
nobody had written down — a module cannot resolve core's `express` either, so core hands that over
|
||||
too (API §7.2).
|
||||
|
||||
**Phase 2 — Core scaffolding, no behaviour change.** One PR each, in order:
|
||||
|
||||
1. `installed_modules` table + the §2.4 state machine.
|
||||
|
||||
Reference in New Issue
Block a user