docs(website): record slice 3, and MODULE_API 1.3.0

The client half's move: 35 files / 5,332 lines (the table said 51 / ~3,700 and
is corrected, not re-derived to match). The seven-kit-members-plus-format claim
held exactly, so the kit needed no additions.

Contract, 1.2.0 -> 1.3.0, all additive: `icon` on a nav item, a third slot
`player.invite.accepted`, and `api.BASE` — which §3.5 specified from the first
draft and shared.js had never published, because nothing needed it until a
module had to build an EventSource URL.

§2.3's narrowing note said game-signup policy was core's business. It was wrong
in both directions: the setting's help text names Bridge.cfg, and slice 1 had
shipped a controller calling a function the narrowing does not expose, so
POST /player/shard/account answered 500 for every caller until this slice.

§3.7 gains the rule the invite slot needed: core may ask whether a slot is
filled when the answer changes CONTROL FLOW, never when it changes decoration.
Decoration goes inside `<Slot wrap>` — that is the footer-separator bug.

§7.7 gains what running it against the real module found, rather than a
throwaway: the unguarded portal icon, the relative-MODULES_DIR trap, and the two
operational notes (a module needs its own npm ci; copy the directory, never
symlink it).

Also recorded: the nav-override cost is worse than "a hidden row may reappear" —
the review instance had the nine UO rows gathered into a dropdown section, and
the whole section is lost. And `api-route-inventory.json` was a slice stale at
228 routes; refreshed to the 158 core actually serves.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 19:00:43 -05:00
parent fc05412070
commit 931f77ef49
5 changed files with 204 additions and 288 deletions

View File

@@ -26,13 +26,20 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.2.0'
const MODULE_API_VERSION = '1.3.0'
```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.3.0 — Phase 3 slice 3, the client half's move.** Three client additions, each because the
extraction needed it: a nav item may carry an **`icon`** component (§3.3), core declares a third slot
**`player.invite.accepted`** (§3.7), and `window.__rg.api` gained **`BASE`** — which §3.5 specified
from the first draft and `shared.js` had never actually published, because nothing needed it until a
module had to build an EventSource URL. Additions only; the server half is untouched and bumps
because the two halves state ONE version.
**1.2.0 — Phase 3, the client half.** `registry` gained `registerExtension` and core gained client
extension slots (§3.7). An addition only, and the first change to `window.__rg` since 1.0.0 — the
server half is untouched, and both files bump because the two halves state ONE version.
@@ -156,7 +163,12 @@ Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
`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.
registration and app-links policy that is core's business. It said *game-signup* policy too until
Phase 3 slice 3, which is when that turned out to be wrong in both directions: the setting's own
help text names Bridge.cfg, so it was never core's — and slice 1 had shipped a ported controller
calling `settings.isGameAccountSignupEnabled()`, which this narrowing does not expose, so
`POST /player/shard/account` answered 500 for every caller until slice 3 found it. A narrowing is
only as safe as the tests that cross it.
- **`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
@@ -579,7 +591,27 @@ registry.registerNav('uo', {
`group` names an existing core group; an unknown group name appends a new group at the end rather
than dropping the item. `order` sorts within the group, core items keeping their current positions.
`feature` names a flag resolved by the provider below.
`feature` names a flag resolved by the provider below. **`icon`** (1.3.0) is a component core renders
exactly as it renders its own rows' icons.
**Core supplies no fallback icon, and both layouts tolerate a row without one.** A module that omits
`icon` gets no icon, the same as a core row that omits it — inventing one would be core making a
presentation choice for content it knows nothing about. The field exists because without it the six
UO rows would have extracted as the only text-only entries in a sidebar where every other row has a
glyph, which reads as breakage rather than as a design; `icon` was already among the fields an
override may **not** touch, so the concept predates a module being able to send one.
A module should match the nav its row lands in rather than ship one glyph for everywhere: the admin
sidebar draws at 18px with a 1.6 stroke and the player portal at 16px with a 2. That is presentation,
deliberately not in the kit — putting core's icon frame in the contract would make changing it a
major bump.
**The tolerance is newer than the field.** `PlayerPortalLayout` rendered `<n.icon />` unguarded,
which was correct for as long as every row in it was core's own and had one; the first module row
without an icon blanked the entire portal with React error #130. Both layouts guard now, and
module-uo's `registration.test.js` asserts an icon on every admin and player row it registers —
belt and braces, because the failure is invisible to a DOM-less test runner and the smoke caught it
only by chance of registering that row last.
Six details settled when this was built (Phase 2 PR 8, `client/src/modules/nav.js`):
@@ -697,6 +729,12 @@ Core exposes the primitive, not the object:
window.__rg.api = { request, ApiError, BASE } // request(path, { method, body, headers, raw })
```
`BASE` is `/api/v1`, and it was specified here from the first draft while `shared.js` published only
the first two — nothing needed it until slice 3, when a module first had to build an **EventSource**
URL. `request` is fetch-only, so an SSE subscriber constructs its own; the alternative is a module
hardcoding `/api/v1`, which asserts something about where core mounts its API that core has never
promised to keep. Published as of 1.3.0.
`request` is `client.js`'s existing `req` — same-origin `/api/v1`, `credentials: 'include'`, JSON
in/out, throwing `ApiError(status, message, body)`. A module builds its own namespace over it and
owns the paths it calls, which is correct: it owns the routes at the other end.
@@ -769,7 +807,8 @@ by checking each imported binding is **identity-equal** to the one core publishe
### 3.7 Extension slots — module content inside a core page
Added in **1.2.0** (settled 2026-08-11). The client twin of §2.4's `registerExtension`, and the same
Added in **1.2.0** (settled 2026-08-11), third slot added in **1.3.0**. The client twin of §2.4's
`registerExtension`, and the same
rule in both halves: **core declares a slot, only core may declare one, and at most one module may
fill it.**
@@ -795,12 +834,13 @@ unfilled case and wrong about the failed one: the slot *is* filled, so the separ
the component then throws into the boundary and leaves the separator behind on its own. Found in a
browser with this exact footer separator, which is the only place it could have been found.
The slots in 1.2.0:
The slots:
| Slot | Rendered in | Props core passes |
| --- | --- | --- |
| `site.footer.status` | `components/SiteFooter.jsx`, in the info row | `linkStyle` — the row's own link styling |
| `admin.users.detail` | `routes/admin/views/UserDetail.jsx`, below the security panel | `userId` |
| `player.invite.accepted` (1.3.0) | `routes/player/AcceptInvite.jsx`, after an invite is accepted | `onDone` — send the invitee on to the portal |
**A slot is named for a PLACE, never for a meaning.** `site.footer.status` is "the status-ish spot in
the footer", not a declaration that core knows what a game server's status is: core supplies the
@@ -819,6 +859,24 @@ binding that leaves core in the client half of Phase 3, so a slot that handed it
something core is about to delete. The extension builds its own client for the routes it registered
at the other end, which is §3.5's rule applied to a slot.
**`player.invite.accepted` is the one slot whose emptiness core reads.** Core rendered a UO
game-account step on that page until slice 3 — it read a `gameAccountSignup` flag out of its own
settings and posted to a shard route — and an invite is a core concept that staff receive too, so the
page stays and its optional step becomes a slot. With the slot unfilled there is no screen to show at
all, so core navigates straight on; with it filled, core renders its shell, the slot, and a "skip"
control *outside* the boundary, because an extension that throws must not take the way out with it.
That makes `extensionFor` legitimate for a core page to call — once, and only for this reason. The
rule is not "never ask whether a slot is filled", it is **ask only when the answer changes control
flow, never when it changes decoration**; decoration goes inside `<Slot wrap>`. `AcceptInvite` is the
only caller in core, and the difference between the two cases is exactly the footer-separator bug
above.
Core's subtitle for that screen says nothing about what the step is. Naming it would be core
describing content it does not own, and there is no wording that stays true for the next game.
**Whether the step should appear at all is the module's decision too** — it is made from a setting
core no longer reads, so the filling component either renders or calls `onDone` itself.
**Errors are contained.** Core renders a filled slot inside an error boundary: a component that
throws costs its own section and a console error, never core's page. That asymmetry is deliberate and
is where the client differs from the server — a module route that throws costs the module's own page,
@@ -1280,3 +1338,23 @@ chunk executes under the **enforced** `script-src 'self'` with no CSP report, wh
(`htmlShell.init`), so rebuilding the client without restarting the server serves an `index.html`
pointing at a hashed bundle that no longer exists — core never runs, `window.__rg` is undefined, and
the failure looks exactly like a contract violation in the module.
**Run it against the real module too, not only a throwaway.** A hand-written chunk proves the
delivery path; it does not prove the module. Slice 3's run — module-uo checked out into
`website/modules/uo`, the deletion branch checked out in `website/` — found two things a throwaway
never would have, and neither is visible to any test in either repo:
- **`PlayerPortalLayout` rendered `<n.icon />` unguarded.** `icon` is optional in the nav contract
and every core row in that sidebar had one, so the difference from `AdminLayout` cost nothing until
a module registered a row without — then it was React error #130 and a **blank player portal**,
not a missing glyph. Both layouts guard now.
- **A relative `MODULES_DIR` failed every module** with `client.entry escapes the module directory`,
because `resolveClient` compared an absolute resolved path against a relative directory. Running
from `server/` is what produces one, so this recipe was the thing that triggered it. `MODULES_DIR`
is resolved absolute now, and the recipe works either way.
Two operational notes for the run itself. The module's own `server/package.json` has dependencies
(`ws`), so a module copied into place needs `npm ci --omit=dev` in its `server/` before it will
`register` — the release tarball carries them, a working copy does not. And **copy the module
directory, do not symlink it**: the loader's `filter(e => e.isDirectory())` reports a Windows
junction as a symlink and skips it silently.