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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user