docs(website): record what the atlas spike proved

Part 7 of MODULE_API.md: the three exit criteria and their results, the two
contract changes the spike forced (ctx.express/ctx.validator and
window.__rg.jsxRuntime), the empirical confirmation of the OpenAPI split, the
loader's tested failure guarantees, and the three artifacts in the branch that
are consequences of stopping at six routes rather than intended shape.

Phase 1 is complete: docs/website/MODULE_API.md is written and the spike met
every exit criterion on website branch spike/module-atlas (cut from edge, never
merged).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 05:30:05 -05:00
parent 8db52c3d14
commit 037f4abad2
2 changed files with 118 additions and 2 deletions

View File

@@ -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.

View File

@@ -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.