Compare commits

..

8 Commits

Author SHA1 Message Date
f3a6231084 docs(website): record slice 1, and MODULE_API 1.1.0
The whole server half is out: 40 files, ~9,674 lines, 27 of 68 tables. The
acceptance criterion held exactly -- core's manifest goes 228 to 158 public
routes and the 70 that left reappear byte-identical once the module loads, with
routes.guards identical across all 228.

The contract grew to 1.1.0: ctx.activity.log, ctx.users.getById,
ctx.site.baseUrl, ctx.middleware.rateLimit + accountChangeLimiter, and a fourth
registry, registerPostHook. Each is documented with why it could not be
vendored, because that reasoning is the useful part -- an admin action a module
performs belongs in core's ONE audit log, a second rate-limit store is a limit
enforced by two counters, and core's CMS was calling a UO file directly.

§2.7.1 gains the slice record: the core.js port mechanism and its consequence
(require order is load-bearing), the vendoring line (pure leaf helpers may be
copied, security controls may not), the two core defects the extraction exposed,
the one deliberate behaviour change, and the one test that looked like it should
move and should not.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:11:09 -05:00
f402395fa5 docs(website): the server half does not slice, and why
Found before writing any of it. §2.7.1 split the server extraction five ways by
feature; it does not divide, because two contract rules compose.

A mount prefix is claimed whole -- ownedByCore probes the live tier router and
registerRoutes validates single-segment prefixes -- and /admin/shard is one
386-line router carrying 25 routes across atlas, clilocs, shard-ops, visibility,
market and account links. Meanwhile a model cannot be shared across the boundary
(§5.1), so it moves with the last route that consumes it. Take the closure and
every prefix is in it: /public/atlas holds shardAtlas with /admin/shard, which
holds shardState/shardEvents/shardMarket with /public/shard, which holds
uoLinkConfig with /admin/uo-link, and shardClilocs/shardLinks with /player/shard.

Landing any one of the old slices alone would either strand core importing
modules/uo/ -- what acceptance criterion 2 forbids -- or delete routes core is
still serving.

Giving the admin routes their own prefixes would divide it and is rejected: it
changes API URLs, which §1.2 promises not to do, and not hypothetically. The
shipped Android app calls POST /api/v1/admin/shard/kick, /ban, /unban,
/broadcast and the three /pages routes.

So the table is now six slices: 0 the skeleton (done), 1 the whole server half
as ONE PR per repo structured as five commits along the old slice lines, 2-4 the
client halves, 5 close the phase. The client half is unaffected and still slices
cleanly -- the registry takes routes per area, with no prefix atomicity and no
shared models, the same asymmetry that let the two halves be separated at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:50:37 -05:00
7548c20820 docs(website): correct the library build, and record slice 0
Slice 0 built the module bundle skeleton against the contract and found that
§3.6 does not work as written. It shows Rollup's `external` alongside the
resolve aliases, and the two do not compose: Rollup asks `external` BEFORE
Vite's alias resolver runs, so a specifier in both is marked external and never
aliased. The chunk then emits bare `import "react"`, which no browser can
resolve without an import map, and CSP forbids the inline script an import map
has to be. It built cleanly and emitted exactly that.

§3.6 is corrected: alias only, `external` empty, with the alias table shown in
full because the anchoring is what stops `react` also capturing
`react/jsx-runtime`. What `external` was guarding -- a missed alias welding a
second React into the chunk -- moves to a resolution-time build plugin, and two
properties of that plugin are now contract because both were wrong first: it
hooks `transform` rather than `load` (first-wins, so it never ran), and its
forbidden-package list is stated rather than derived from the alias list
(deriving it means deleting an alias also deletes the guard).

Also records slice 0's outcome in §2.7.1, including the finding that generalises
past this repo: the boundary check failed on its own documentation, because the
comments describing what it catches are written in the syntax it catches. Slice
8's §5.2 grep has the same problem waiting for it. And the loader skips a
SYMLINKED module directory silently, which is the first thing to check when a
module fails to appear locally.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:33:38 -05:00
749233d378 docs(website): settle Phase 3's shape, slices and merge order
Phase 2 is closed, so Phase 3 needs a plan before any of it is extracted.
Four decisions, and one finding that set the first of them.

The finding: because API URLs are preserved (§1.2), a feature's server and
client halves are independent. Core's client keeps calling
/api/v1/public/shard/status after the module serves it, and a module page
calls the same URL while core still does. Nothing forces vertical slices, so
the extraction is server-first then client, sliced by feature, ten slices.

Merge order within a slice is module-uo first, then website. The loader's
ownedByCore probe stops a module LOADING while core owns its prefix, but the
module's own CI never loads it into core, so its PR merges fine beforehand --
and edge then serves the feature from core right up to the moment core drops
it, with no window where the branch is missing it outright.

Criterion 1's grep reads code, not prose: filenames, import specifiers, route
path literals and declared identifiers. Core's marketing copy legitimately
says "shard" in a dozen places and a literal word grep would have failed CI on
each while proving nothing about the boundary. That copy is rewritten in its
own slice instead, which is real work with a real review rather than an
exemption hidden in a pattern.

module-uo's CI clones core at a pinned ref to freeze its route manifest --
nothing else proves the URLs it claims are the URLs it serves -- and the
module-rust dry run lands in docs/modules/ where §2.10 already aggregates
module documentation.

Also records the measured surface (72 server files, 51 client, 32 test files),
which supersedes the Phase 0 estimate, and the one kit gap: lib/format.js is
vendored by the module rather than becoming an eighth §3.4 member.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:10:13 -05:00
12e4eaad10 Merge pull request 'docs(website): Phase 2 PR 9 — the modules mount, and Phase 2 complete' (#134) from docs/module-compose-volume into main
Reviewed-on: #134
2026-08-11 05:57:58 +00:00
8a7b099c2d docs(website): Phase 2 PR 9 — the modules mount, and Phase 2 complete
Records the last Phase 2 PR and closes the phase.

Amends §2.5: the mount is a bind mount of ./modules, not the named volume the
section reached for by analogy with uploads. Hand-placing a module directory is
a supported install in that same section, and a named volume routes it through
`docker cp` — the least discoverable mechanism Docker offers, for the one
install path an operator without the admin panel has.

Two things the build settled that the plan had not considered, both silent
failures rather than errors: the directory has to be tracked, because Docker
recreates a missing bind-mount source as root-owned and the container is uid
1000; and .dockerignore has to exclude it, because COPY . . would otherwise bake
a builder's checked-out module into every image — and Docker seeds a fresh named
volume from image contents, so it could have surfaced on a deployment that never
installed it.

MODULE_API §4.1 gains the concrete Compose values and states outright that a
missing modules directory is not an error, which the loader has always done and
the contract never said.

Website side: RunicGateway/website#136.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:50:17 -05:00
d66ee832c5 Merge pull request 'docs(website): Phase 2 PR 8 — the nav interleave and the feature-provider seam' (#133) from docs/module-nav-interleave into main
Reviewed-on: #133
2026-08-11 05:29:18 +00:00
f740530eb8 docs(website): settle the nav interleave and the feature-provider seam
Records Phase 2 PR 8 of the module system: MODULE_SYSTEM.md gains the PR
entry in 2.7 and the built-it notes on 1.4 and 1.5; MODULE_API.md 3.3 is
amended where building it settled something the draft left open or got wrong.

The amendments to 3.3:

- The pipeline arrow had role/feature filtering BEFORE admin overrides. The
  code has always been the other way round, deliberately - the filter runs
  last so it stays a boundary an override cannot cross (THEMING_AND_NAV 7).
- `feature` was documented as public-area-only. It applies in all three
  areas: core's admin and player navs still carry no flags, but a module row
  that declares a gate and has it silently ignored is a trap.
- How a provider is found: by the module that registered the row, not by a
  prefix parsed out of the flag name. Core's own rows resolve against the
  owner id `core`, which core registers useShardFlags under.
- What a provider hook returns, and that every unknown fails OPEN.
- Why calling one hook per provider in a loop is legal, and why the
  enumerator is a module export rather than a member of registry.
- Six details the interleave settled: unordered rows append rather than
  defaulting to 0; an ungrouped admin row gets its own trailing group rather
  than joining core's; a module-created group is a legal override
  destination; a colliding `to` is dropped with a warning; and why the
  interleave must precede the override merge.

MOD_PATHS' replacement is recorded in both files, including the defect the
derivation fixed: the moderator redirect was a third hardcoded list that
disagreed with MOD_PATHS about /admin/houses. THEMING_AND_NAV 7's
"moderator confinement" note is amended to match.

Code: website PR 8 (client-only; 160 client tests, manifest and OpenAPI
unchanged), verified with the 7.7 browser smoke.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:42:21 -05:00
3 changed files with 496 additions and 29 deletions

View File

@@ -26,9 +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.0.0'
const MODULE_API_VERSION = '1.1.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.1.0 — Phase 3 slice 1.** `ctx` gained `activity.log`, `users.getById`, `site.baseUrl`, and
`middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`.
Additions only. Each exists because module-uo's extraction needed it and none could be vendored — an
admin action a module performs belongs in core's one audit log, the extension slot needs the user its
prefix names, §2.7 forbids a module reading core's `APP_BASE_URL`, a second rate-limit store is a
limit enforced by two counters, and core's CMS was calling a UO file directly.
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.
@@ -128,6 +139,11 @@ module-uo does not need is on the list.
| `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/<id>/` | loader | atlas art, cliloc files |
| `ctx.activity.log` | `({ req, action, detail }) => Promise<void>` | `model/activity` | every admin UO controller (1.1.0) |
| `ctx.users.getById` | `(id) => Promise<user\|null>` | `model/users` | `usersShard.controller` (1.1.0) |
| `ctx.site.baseUrl` | getter, string with no trailing slash | `APP_BASE_URL` | `shardAnnounce` (1.1.0) |
| `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) |
| `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) |
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
@@ -159,6 +175,7 @@ api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
api.registerExtension(slot, router)
api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -240,6 +257,24 @@ next_attempt_at)` and `leg` is a stored value. The parent `status` rollup is ove
legs — done when every leg delivered, failed when every leg gave up, partial in between; and `done`
when a job has no legs at all, since nothing is left to deliver.
**`registerPostHook({ onSaved, onDeleted })`** — added in API 1.1.0. Core's CMS is the only writer of
posts, and a module may need to mirror one somewhere core knows nothing about. `onSaved` receives
`{ post, transition }` — the same transition `registerAnnounceLeg` fires on — and `onDeleted`
receives `{ post, id }`. Both are optional; a registration with neither is refused, since it is a
subscription that can never fire. One hook per registrant.
Every hook is awaited and none may throw past core: a subscriber's failure is logged and costs
neither another subscriber nor the save itself. A sidecar hiccup breaking a post edit would be a
worse bug than a stale mirror.
**It is deliberately not part of `registerAnnounceLeg`**, which fires on the same transition. A leg
is a one-shot *delivery* with retry and classification; a post hook maintains idempotent *state*, has
to run on delete as well as save, and refreshes silently on an edit. Overloading the leg would have
meant a `dispatch` that must not be retried and a `classify` that means nothing.
Before it existed, core's post controller required `utils/newsGump` directly — core's publish path
naming a UO file, and the last thing binding core to the module.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
@@ -539,20 +574,82 @@ 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` (public area only) names a flag resolved by §3.3's provider.
`feature` names a flag resolved by the provider below.
`MOD_PATHS` in `AdminLayout.jsx:109` — today a hardcoded allowlist of two UO paths — becomes a
computation over each item's `roles`, so moderator visibility follows from the registration instead
of from a second list that has to be kept in sync.
Six details settled when this was built (Phase 2 PR 8, `client/src/modules/nav.js`):
- **`order` on a flat nav is a position among core's rows**, which are keyed by their index; an
explicit `order` beats a core row that merely sits at that index. A row with **no** `order`
appends after the coded rows rather than defaulting to 0 — otherwise "I didn't ask for a
position" would mean "put me first", which is the one place a module could take over the header
without asking for anything.
- **A row with no `group` on the admin sidebar gets a trailing untitled group of its own**, not a
place in one of core's untitled groups. Those are Dashboard at the top and Account at the bottom;
a module page belongs beside neither, and core does not invent a display title out of a module id.
- **A group a module created is itself a legal override destination.** It falls out of building the
destination set from the merged base nav, and is recorded so it is not mistaken for an accident.
- **A row whose `to` collides with an existing row is dropped, with a console warning.** `to` is the
key the override layer stores under and React renders by, so two rows sharing one would give an
admin a single editor row that silently moves both. Core's row wins, since that is the one any
stored override was written against.
- **`feature` is not public-area-only.** An earlier draft of this section said it was, on the
grounds that core's admin and player navs carry no flags. They still do not — but a module row
that declares a gate and has it silently ignored is a trap, so the gate is applied in all three
areas and the field means one thing everywhere.
- **The interleave happens before the override merge, and that ordering is load-bearing.** The merge
is keyed by `to` and drops any key its base array does not declare, so module rows appended
afterwards would be unorderable, unrelabellable and unhideable — a visible regression for every
operator who has ever edited a nav, the day the UO rows leave core.
Moderator confinement, `MOD_PATHS` in `AdminLayout.jsx:109` — a hardcoded allowlist of five paths —
becomes a computation over each item's `roles`, so moderator visibility follows from the
registration instead of from a second list that has to be kept in sync. It moved to
`client/src/lib/adminNav.js`, plain JS so the test runner can reach it, along with the redirect that
confines a moderator who deep-links. **The redirect derives from the base nav, never the
override-merged one**: an override is presentation and must not move an authorization boundary in
either direction — hiding a row must not also bar someone from the page, and un-hiding one must not
admit them to it.
Deriving it changed what a moderator sees, in both cases toward what the server already permitted:
**Dashboard**, whose `roles` had always named moderator while `MOD_PATHS` omitted it, and **My
Characters**, which is ungated self-service. It also fixed a defect the two lists had between them —
`/admin/houses` was on the sidebar and not in the redirect's own third list, so a moderator clicking
Houses in their own nav was bounced back to Moderation.
The pipeline is unchanged from `THEMING_AND_NAV.md`, with one new first step:
> registered defaults (core **+ modules**) → role/feature filtering → admin overrides → rendered nav
> registered defaults (core **+ modules**) → admin overrides → role/feature filtering → rendered nav
An earlier draft of this line had the last two the other way round. The filter runs **last** and
that is deliberate — it is what keeps it a boundary an override cannot cross (`THEMING_AND_NAV.md`
§7), and both layouts have always been written that way.
**`registerFeatureProvider`** — core keeps a generic flag context; the module supplies the hook that
fills its namespace (`useShardFeatures` for `uo`). With no module installed the filter is a correct
no-op, because no core nav item carries a `feature` today.
**The namespace comes from the registration, not from the string.** A row's `feature` is resolved by
the provider its own module registered, so a module author writes `feature: 'status'` exactly as it
reads today: nothing parses a prefix, and a typo'd namespace is not a thing that can exist. Core's
own rows carry no `moduleId` and resolve against the owner id **`core`** — which is what core
registers `useShardFlags` under (`main.jsx`), the client twin of the server's `registries.registerCore()`.
So the ten shard-gated rows in the public header already run through the module seam rather than
beside it, and Phase 3 deletes core's registration instead of rewriting the header.
A provider hook returns **a Set-like of the flags this viewer may see, or `null`** while the answer
is in flight. Every unknown — no provider, a null answer, a provider that returned something without
a `has`, a malformed row — **shows the link**. This is presentation and the server is the gate, so a
UI mistake that hides a page from someone entitled to it is worse in every case than one that shows
a link which then 403s.
Core calls every registered provider's hook unconditionally, in a fixed order, at the top of the
context component. That is legal because the rules of hooks require the same hooks in the same order
on every render of a component, not a statically known list: registration completes before the first
render (§3.1), nothing unregisters, and the provider list is snapshotted per component instance
anyway. `registry.featureProviders()` is a module export and deliberately **not** a member of the
`registry` object handed to modules — a module asks for a namespace it knows the name of, and has no
business enumerating what everyone else registered.
### 3.4 `ui` — the shared component kit
**This is the largest addition Phase 1 makes to the plan, and it is not optional** (§6.2; approved
@@ -601,27 +698,33 @@ owns the paths it calls, which is correct: it owns the routes at the other end.
### 3.6 Vite library-mode build
The module's `vite.config.js`, and the four externals are the whole contract:
The module's `vite.config.js`, and the shared-dependency aliases are the whole contract:
```js
export default defineConfig({
plugins: [react()],
plugins: [react(), assertSharedNotBundled()],
resolve: {
// ARRAY form with ANCHORED regexes. The object form does PREFIX matching, so
// a `react` key silently also rewrites `react/jsx-runtime`.
alias: [
{ find: /^react$/, replacement: shim('react') },
{ find: /^react\/jsx-runtime$/, replacement: shim('jsx-runtime') },
{ find: /^react\/jsx-dev-runtime$/, replacement: shim('jsx-runtime') },
{ find: /^react-dom$/, replacement: shim('react-dom') },
{ find: /^react-dom\/client$/, replacement: shim('react-dom') },
{ find: /^react-router-dom$/, replacement: shim('react-router-dom') },
],
},
build: {
lib: { entry: 'src/entry.jsx', formats: ['es'], fileName: () => 'entry.js' },
outDir: 'dist',
modulePreload: { polyfill: false }, // same reason as core: no inline bootstrap under CSP
rollupOptions: {
external: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
output: { paths: { /* rewritten to window.__rg by the shim below */ } },
},
rollupOptions: { external: [] }, // deliberately empty — see below
},
})
```
Rollup's `external` alone emits bare `import 'react'` specifiers, which the browser cannot resolve
without an import map — and CSP forbids the inline `<script type="importmap">` that would provide
one (`MODULE_SYSTEM.md` §1.14). The module therefore ships a two-line shim module that re-exports
from the global, and aliases the four externals to it:
Each aliased specifier resolves to a two-line shim that re-exports from the global:
```js
// src/shim/react.js
@@ -629,9 +732,35 @@ export default window.__rg.react
export const { useState, useEffect, useMemo, useCallback, useRef, createElement, Fragment } = window.__rg.react
```
**This is the highest-risk mechanical detail in the whole plan and it is exactly what the Phase 1
spike exists to prove.** If it does not hold, §2.6 of the design of record is wrong and the client
half needs rethinking before Phase 2 builds on it.
**Corrected 2026-08-11, Phase 3 slice 0: `external` and the aliases do not compose, and this section
used to show both.** Rollup asks `external` *before* Vite's alias resolver runs, so a specifier
listed there is marked external and never aliased. The chunk then emits bare `import 'react'`
specifiers, which the browser cannot resolve without an import map — and CSP forbids the inline
`<script type="importmap">` that would provide one (`MODULE_SYSTEM.md` §1.14). Slice 0 shipped with
both, built cleanly, and emitted exactly that chunk. So: **alias only, and `external` empty.**
What `external` was there to guard is real — an alias that misses means a second React welded into
the chunk, which loads fine and then throws about an invalid hook call somewhere unrelated. That is
guarded instead by a **resolution-time plugin that fails the build if a shared dependency resolves
into `node_modules`**. Two things about it are contract, because both were wrong first:
- **It hooks `transform`, not `load`.** `load` is first-wins, so an earlier plugin returning the
module's contents means the guard is never called for it. Written against `load` it sat in the
build doing nothing, and a deliberately-broken alias produced a 24 kB chunk with react-router
bundled and a green build.
- **Its forbidden-package list is stated, not derived from the alias list.** Deriving it "so the two
cannot disagree" means deleting an alias also deletes the guard against what that alias prevented
— which is exactly when it is needed. What may not be bundled is a fact about `window.__rg`; a
test asserts the aliases stay inside it.
The module's own boundary checks are `scripts/checkImports.js` (§5.1) and `scripts/checkExternals.js`,
which asks the **built chunk** whether any bare specifier survived. That question cannot be asked of
source: `import { useState } from 'react'` is correct in every file, and which React it becomes is
decided here.
**This is the highest-risk mechanical detail in the whole plan.** The Phase 1 spike proved the
approach; slice 0 proved the configuration, in a browser, under the enforced `script-src 'self'`,
by checking each imported binding is **identity-equal** to the one core published.
---
@@ -641,7 +770,13 @@ half needs rethinking before Phase 2 builds on it.
`app.js` scans `modules/*/module.json` with `fs.readdirSync` at require time and mounts what it
finds (`MODULE_SYSTEM.md` §1.12). The database is not consulted. `MODULES_DIR` defaults to
`<repo>/modules` and is overridable by env for tests and for the Docker volume mount.
`<repo>/modules` and is overridable by env for tests and for the Docker mount. Under Compose it is
set to `/app/modules`, where `./modules` is bind-mounted read-write (`MODULE_SYSTEM.md` §2.5); the
image itself carries that directory empty and owned by the container user, and `.dockerignore`
excludes any local one so a module can never be baked in.
**A missing modules directory is not an error.** The scan catches and returns, because "no modules
installed" is the normal state of bare core and the loader must not make the mount mandatory to boot.
**The trigger is one explicit call, and there is no lazy self-scan** (§7.6). `app.js` calls
@@ -749,6 +884,25 @@ specifiers in the client bundle are the four declared externals. A hit fails the
Phase 3's acceptance criterion 1: no `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` outside
`modules/`, as a CI grep test rather than a review promise.
**Settled 2026-08-11: the grep reads code, not prose.** It covers four things, and each of them is a
thing a module owns:
1. **File and directory names** under `server/src/`, `server/scripts/`, `server/db/` and `client/src/`.
2. **Import and require specifiers** — the path in `require('…')` / `from '…'`.
3. **Route path literals** — the string arguments to `.get`/`.post`/`.put`/`.patch`/`.delete`/`.use`.
4. **Declared identifiers** — function, const, class and property names.
It does **not** read comments or string content generally, and that is not a loophole. Core's
marketing copy legitimately says "shard" — `About.jsx`, `Screenshots.jsx`, `SiteFooter.jsx`,
`heroLayout.js` — and a literal word grep would turn each of those into a CI failure while proving
nothing about the boundary. Worse, it would forbid a core comment from ever using the word as an
example, which is the sort of rule people work around rather than obey. The boundary this test exists
to defend is *structural*: core must not name a module's files, import them, route to them, or
declare their symbols. It can talk about them in English.
Core's UO-flavoured default copy is dealt with directly instead, as `MODULE_SYSTEM.md` §2.7.1's
slice 4 — a rewrite with its own review, not an exemption.
### 5.3 Zero-line route manifest diff (CI, both repos)
`npm run routes:manifest -- --check` in core; the module generates and freezes its own manifest in
@@ -756,6 +910,16 @@ its own repo, using the same script pointed at a core+module app. Phase 2 must p
diff in core's; Phase 3 moves the UO entries out of core's and into module-uo's, which is the one
diff the whole workstream is allowed.
**Settled 2026-08-11: `module-uo`'s CI checks core out at a pinned ref.** The module's workflow
clones `RunicGateway/website` at a ref recorded in the module repo, drops itself in as `modules/uo`,
and runs core's own `routeManifest.js`. Nothing else proves the URLs a module claims are the URLs it
actually serves — a manifest frozen by hand goes stale silently, and the failure it would have caught
is a route that moved.
Pinning the ref rather than tracking `edge` is what keeps this from being a source of unexplained red
Xes: core moves for reasons that have nothing to do with the module, and a bump is then a deliberate
commit that says which core the module was last proved against.
---
## Part 6 — Amendments to MODULE_SYSTEM.md

View File

@@ -97,6 +97,14 @@ moderator-visible.
**Resolved:** nav registration takes a target group and order (`{ group: 'Moderation', order: 30 }`),
and `MOD_PATHS` becomes a `roles`-derived computation rather than a path allowlist.
Built in Phase 2 PR 8. Two things it turned up that this section did not predict. The interleave has
to happen **before** the admin-override merge and not after it, because that merge drops any `to`
its base array does not declare — appending module rows afterwards would leave them uneditable in
Admin → Navigation, which today's UO rows are not. And there was a **third** hardcoded list: the
redirect that confines a moderator checked three path prefixes, while `MOD_PATHS` listed five paths,
and they disagreed about `/admin/houses` — a moderator who clicked Houses in their own sidebar was
bounced straight back to Moderation. One derivation cannot disagree with itself.
### 1.5 The public nav's feature-gating mechanism is itself a shard system
Ten of the sixteen entries in `SiteHeader.jsx`'s NAV carry a `feature:` key (`status`, `champs`,
@@ -108,6 +116,12 @@ Extracting the module removes the provider that core's own nav filter depends on
registers its `useShardFeatures` for its own namespace. No core nav item carries a `feature` today,
so with no module installed the filter is a correct no-op.
Built in Phase 2 PR 8, and core registers into it **now** rather than at extraction: `useShardFlags`
goes in under the owner id `core`, so the ten rows above are already resolved through the seam and
`SiteHeader` runs one mechanism instead of two. Which provider answers a row is decided by the
module that registered it, not by a prefix parsed out of the flag name, so those ten keep the exact
strings they carry today and Phase 3 moves them without a rename.
### 1.6 There is no migration system to model a module migration runner on
`server/db/schema.sql` is a single idempotent file — 1,380 lines, 67 tables — replayed in full on
@@ -414,8 +428,12 @@ leaves the row `disabled`, which the reset never touches.
### 2.5 Install, uninstall, purge
Modules live on a **mounted volume**, not in the image — the same treatment `uploads` already gets in
`docker-compose.yml`. That is what makes the WordPress model work against a pull-only image.
Modules live on a **mounted volume**, not in the image. That is what makes the WordPress model work
against a pull-only image. *(Settled in Phase 2 PR 9: it is a **bind mount** of `./modules`, not the
named volume this sentence originally reached for by analogy with `uploads` — hand-placing a module
directory is a supported install below, and a named volume would route it through `docker cp`. The
image's copy is excluded by `.dockerignore`, so a module in a builder's working tree can never ship
inside an image; see `modules/README.md` in the website repo.)*
**Install:** admin selects the module → bundle downloaded from the module repo's release and verified
against its `sha256` → unpacked into `modules/<id>/` on the volume → `installed_modules` row written →
@@ -497,14 +515,14 @@ too (API §7.2).
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, the chunk's static
mount and the `htmlShell` script injection — empty registry, no visible change.
8. `MOD_PATHS` → `roles`-derived (§1.4); the generic feature-provider seam (§1.5).
9. `docker-compose.yml` gains the `modules` volume.
9. `docker-compose.yml` gains the `modules` mount.
Exit criterion: no **existing** URL moves and every existing test passes. If Phase 2 changes one URL,
it is wrong. PR 6 is the single deliberate exception in the phase and it *adds*: `routes.manifest.json`
gains exactly one line, `GET /api/v1/public/modules`, and nothing else in the file moves. Every other
PR in Phase 2 produces a zero-line diff.
**Progress: PRs 1-7 done.**
**Progress: complete — all nine PRs landed.**
- **PR 1** — `installed_modules` and the state machine, with the stored shape and the boot rules
settled in §2.4 above.
@@ -627,6 +645,91 @@ fixed enum because the leg set is whatever has been registered.
filesystem-conditional static mount, not API surface, for the same reason `/uploads` and `/brand`
are not in the manifest.
- **PR 8** — the nav half PR 7 deferred, and the two seams §1.4 and §1.5 asked for: `withModuleNav`
(`client/src/modules/nav.js`) interleaving module rows into core's three navs, `MOD_PATHS` and the
moderator redirect replaced by a `roles`-derived computation in `client/src/lib/adminNav.js`, and
the generic feature-provider seam (`modules/features.jsx` + `modules/featureGate.js`) that core
registers its own `useShardFlags` into. Four decisions, all recorded in
[`MODULE_API.md`](MODULE_API.md) §3.3.
**The interleave happens before the admin-override merge**, which is the decision the rest follow
from: the merge is keyed by `to` and drops any key its base array does not declare, so module rows
appended after it would be unorderable, unrelabellable and unhideable — and today's UO rows are
all three of those things, so appending would make the extraction a visible regression for every
operator who has ever edited their nav. Doing it first means a module row is an ordinary row to
everything downstream: nothing in `navOverrides.js`, `NavEditor.jsx` or the layouts knows a module
exists. **Moderator visibility derives purely from `roles`**, which moves two rows the old
allowlist withheld — Dashboard, whose `roles` had always named moderator, and My Characters, which
is ungated self-service — both toward what the server already permitted. **A row's `feature` is
resolved by the provider its own module registered**, so the namespace comes from the registration
rather than from a parsed string prefix. And **core registers through the same seam**, under the
owner id `core`, so `SiteHeader` holds one mechanism instead of two and Phase 3 is a deletion.
The PR also fixed a defect that predates the module system: the moderator redirect was a **third**
hardcoded list, and it disagreed with `MOD_PATHS` about `/admin/houses`, so a moderator who
clicked Houses in their own sidebar was bounced back to Moderation. The derived allow-list is
computed from the **base** nav rather than the merged one, so an override — which is presentation
— cannot move that boundary in either direction.
**933 server tests** (unchanged — this PR is client-only) and **160 client tests** (+37) pass;
`routes.manifest.json` is unchanged at 230 routes and the OpenAPI spec regenerates byte-identical.
The [§7.7](MODULE_API.md#77-the-client-half-has-to-be-verified-in-a-browser--the-timing-bug-no-test-could-see)
browser smoke was re-run, since this is the seam that rule exists for: a throwaway module
registering nav in all three areas and a provider granting one flag and withholding another. It
confirmed, in Chrome with the console open, that the row lands inside core's Moderation group
rather than in an appended block, that the withheld row does not render while the granted one
does, that a moderator reaches both `/admin/houses` and the module's own admin page, and that an
admin can relabel a module row in Admin → Navigation and have it persist and apply — the whole
point of merging before the override layer. Zero CSP reports, zero console errors.
- **PR 9** — the mount itself, which closes the phase: `docker-compose.yml` gains `./modules` at
`/app/modules`, the `Dockerfile` creates that directory node-owned, `.dockerignore` keeps any local
one out of the image and `modules/README.md` documents the directory for whoever opens it.
It is a **bind mount, not the named volume** §2.5 assumed by analogy with `uploads`. Placing a
module directory by hand is a supported install, and a named volume makes that a `docker cp` into a
running container — the one install path an operator without the admin panel has, routed through
the least discoverable mechanism Docker offers. A bind mount makes it `tar -xf … -C ./modules`, and
makes the installed set something an operator can *see*. §2.5 is amended above; nothing else about
install, uninstall or purge changes.
Two consequences worth stating, because both are silent failures rather than errors. The directory
is **tracked** — via its README, the same shape `brand/` already uses — because Docker recreates a
missing bind-mount source as `root:root`, and the container runs as uid 1000: delete `modules/` from
a checkout and the next install fails on a permission error that names no cause. And the mount is
**read-write**, since §2.5's install unpacks into it from inside the container; deferring that to
Phase 4 would have bought nothing, as a mount-mode change is a redeploy either way.
`.dockerignore` matters more than it looks. `COPY . .` would otherwise bake whatever module the
builder had checked out into every image — and because Docker seeds a *fresh* named volume from
the image's contents, that module could have appeared on a production deployment that never
installed it. The exclusion is what makes "modules live on a mount, never in the image" true rather
than merely intended.
Verified against a **running container**, which is the only thing that can check any of the above —
a compose file that parses proves nothing about ownership, and nothing about what the image
contains. The image carries an empty, node-owned `/app/modules` despite a module sitting in the
build context. A module on the mount loads, mounts, runs `onBoot` and reaches `started`;
`/api/v1/public/modules` lists it; its chunk serves from the entry's directory alone, with the
module's own server source and `module.json` both `404`. The [§7.7](MODULE_API.md#77-the-client-half-has-to-be-verified-in-a-browser--the-timing-bug-no-test-could-see)
browser smoke was re-run against the containerised stack rather than a working tree: in Chrome, the
page renders on first paint inside core's `PublicLayout`, drawing React and the UI kit off
`window.__rg`, with its nav row interleaved into core's public nav — under the enforced
`script-src 'self'`, with zero CSP reports and no console errors. Removing the directory by hand and
restarting reconciles the row to `startup_failed`/`require` exactly as §2.4 says, and leaves core
healthy with no script injected and `{"modules":[]}` published.
**933 server tests** and **160 client tests** pass, both unchanged — this PR ships no application
code. `routes.manifest.json` stays at 230 routes and the OpenAPI spec regenerates byte-identical.
The one source change is a comment: `scripts/routeManifest.js` enumerated the filesystem-conditional
mounts it excludes and had never been told about `/modules`. Its filter is an allowlist, so the
behaviour was always right and only the explanation was stale.
**Phase 2 is complete.** Core can discover, validate, mount, migrate, boot, publish, serve and
navigate a module it does not contain, on a deployment that builds nothing — and it does all of that
while no existing URL has moved. The exit criterion held: `routes.manifest.json` went 229 → 230 across
the whole phase, and the one added line is PR 6's deliberate `GET /api/v1/public/modules`.
**Phase 3 — Extract `module-uo`.** Moves out of `website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus
@@ -643,7 +746,193 @@ Acceptance, all four required:
ownership move, which changes no URL. After extraction the core manifest no longer contains UO
routes — `module-uo` generates and freezes its own in its own repo.
4. **A written `module-rust` dry run** — manifest, mounts, nav entries, one notification stream — not
implemented, to prove the contract generalises before more is built on it.
implemented, to prove the contract generalises before more is built on it. It lands as
`docs/modules/rust-dryrun.md`, where §2.10 already aggregates module documentation; Phase 5's
Integration Kit links to it rather than copying it, per the kit's own never-re-specify rule
(§2.11).
#### 2.7.1 Phase 3's shape — settled 2026-08-11
Measured against `edge` at the close of Phase 2, the surface is **72 server files / ~9,700 lines**,
**51 client files / ~3,700 lines**, and **32 of core's 82 server test files**. The counts in the
paragraph above were written in Phase 0 against a smaller tree and are superseded by the slice table
below.
**The finding that sets the order: the two halves are independent.** Because §1.2 preserves API URLs
exactly, core's client keeps calling `/api/v1/public/shard/status` after that route is served by the
module, and a module page calls the same URL while core still serves it. Nothing forces a feature's
server and client halves to move together, so the extraction is **server-first, then client**, sliced
by feature — which keeps each PR inside one layer and one review's worth of context.
**Merge order across the two repos: `module-uo` first, then `website`.** The loader's `ownedByCore`
probe means a module cannot *load* while core still owns its prefix — but `module-uo`'s own CI never
loads it into core, so its PR merges perfectly well beforehand. Taking that order means `edge` serves
the feature from core right up to the moment core drops it, and there is never a window where the
branch is missing a feature outright. The reverse order would break `edge` at every slice boundary
for the length of a review. Verification is unaffected either way: a slice is proved by running the
*pair* together locally — the module branch checked out into `website/modules/uo`, the deletion
branch checked out in `website/` — before either merges.
Each slice is one `module-uo` PR (adds), one `website` PR (deletes), and one `docs` PR:
| # | Slice | Moves |
| --- | --- | --- |
| 0 | **The bundle skeleton** | `module.json`, both `package.json`s, `server/index.js` registering nothing, the Vite library build + the four shared-dep shims, CI armed, the §5.1 zero-internal-imports check. `website` untouched. |
| 1 | **The whole server half** | 40 files / ~9,674 lines, 25 of core's 82 test files, 27 of its 68 tables — every UO model, util, router and controller, `config/shardStreams.js`, `scripts/importSpawnAtlas.js` and the art JSON. **One merge, five commits** (below). |
| 2 | **Public pages** | `Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`, `PlayersOnline`, `VendorSales`, `data/cityCrests.js`, `lib/shardEvents.js`, `lib/useShardFeed.js`, and their public nav rows — under `/uo/*` per §2.8 |
| 3 | **Admin + player pages** | `ShardAdmin`, `ShardOps`, `ShardVisibility`, `SpawnAtlas`, `HousesAdmin`, `AdminCharacter(s)`, `PlayerCharacter(s)`, `GameAccounts`, `CharacterSheet`, `CharacterStats`, `ShardAccountActions`, `CreateGameAccountForm`, `useShardFeatures` and the `useShardFlags` feature provider — under `/admin/uo/*` and `/player/uo/*` |
| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `SiteFooter`, `heroLayout`'s defaults, `api/client.js`'s `shard`/`atlas` namespaces, and the comments in `navOverrides.js` — plus the §5.2 CI grep that keeps them out |
| 5 | **Close the phase** | `module-uo`'s frozen route manifest and release workflow; `docs/modules/uo/` and `docs/modules/rust-dryrun.md` |
##### Why the server half cannot be sliced — found 2026-08-11, before writing any of it
The table above used to run to ten slices, with the server half split five ways by feature. It does
not divide, and the reason is that two contract rules compose:
- **A mount prefix is claimed whole.** `ownedByCore` probes the live tier router and `registerRoutes`
validates single-segment prefixes, so `/admin/shard` moves as one unit — and it is a single
386-line router carrying 25 routes that span atlas, clilocs, shard-ops, visibility, market *and*
account links.
- **A model cannot be shared across the boundary** (§5.1), so a model moves with the *last* route
that consumes it.
Take the closure and every prefix is in it:
```
/public/atlas ──shardAtlas── /admin/shard ──shardState,shardEvents,shardMarket── /public/shard
│ │
shardClilocs,shardLinks uoLinkConfig
│ │
/player/shard /admin/uo-link
```
Landing any one of the old slices alone would either strand core importing `modules/uo/` — which is
precisely what acceptance criterion 2 forbids — or delete routes core is still serving.
**Giving the admin routes their own prefixes would divide it, and is rejected.** `/admin/atlas` and
`/admin/clilocs` alongside a slimmer `/admin/shard` would make the closure fall apart. It also
changes API URLs, which §1.2 promises not to do — and not hypothetically: the shipped Android app
calls `POST /api/v1/admin/shard/kick`, `/ban`, `/unban`, `/broadcast` and the three `/pages` routes
(`data/api/AdminApi.kt`). A prefix rename is a client break, and the API surface is frozen for
exactly this reason.
**So slice 1 is one PR per repo, structured as five commits** along the old slice lines, reviewable
one at a time while landing atomically: atlas + clilocs · the live shard · market · account links and
the `admin.users.detail` slot · the town-crier leg. The alternative considered was stacked PRs into a
per-repo integration branch; it buys PR-level granularity for ten extra PRs and two long-lived
branches, and commits give most of the same reading order for none of it.
**The client half is unaffected** and still slices cleanly: the client registry takes routes per
*area*, with no prefix atomicity and no shared models — which is the same asymmetry that let the two
halves be separated in the first place.
**Criterion 1 is a grep over code, not over prose** — see [API §5.2](MODULE_API.md#52-zero-uo-identifiers-in-core-ci-website-repo)
for what that means precisely. Core's marketing copy says "shard" in a dozen places, and a literal
word grep would have made every one of them a CI failure while proving nothing about the boundary.
Slice 4 rewrites that copy anyway, because a core that still reads as a UO site is not the
game-agnostic platform this workstream is for — but it is a deliberate piece of work with its own
review, not an exemption hidden in a grep pattern.
**One small gap in the kit, deliberately not closed.** The UO client views import almost exactly the
seven §3.4 members — plus `lib/format.js`, a pure leaf formatter. The module **vendors a copy**
rather than core adding an eighth member: the kit is closed on purpose, and a function with no
props and no layout cannot drift the way a component can. The same is not true of `PublicLayout`,
which is why that one is in the kit.
#### Slice 0 — the bundle skeleton (Module-uo#2, 2026-08-11)
`module.json`, an entry point taking `(ctx, api)`, the Vite library build, four shims, and both
boundary checks. It **registers nothing**, and core is untouched — what it proves is the delivery
path itself, before a single UO file moves into it. 29 server tests and 9 client tests, both new.
Verified against a real core rather than asserted: the module loads, mounts its zero routes, reaches
`started` and is published by `/api/v1/public/modules`; its chunk serves from the entry's directory
with `Cache-Control: no-cache` while its server source, `module.json` and `package.json` all 404;
and in Chrome, under the enforced `script-src 'self'`, the chunk reports every shared dependency
**identity-equal** to core's, with zero CSP reports.
**Three findings, each of which had produced a green build that was wrong.** The first amends the
contract and is written up at [API §3.6](MODULE_API.md#36-vite-library-mode-build): `external` and
the aliases do not compose, so `external` is now empty and a resolution-time build guard replaces
it. The second is that guard's own two failures — hooking `load` (first-wins, so it never ran) and
deriving its forbidden list from the alias list (so deleting an alias deleted the guard). Both were
found by breaking an alias on purpose and checking the build actually went red, which is the only
way a guard's absence is visible.
The third is about the boundary check itself and generalises past this repo. **`checkImports.js`
failed on its own documentation** — the comment naming `require("../../etc/passwd")` as an example
of what to catch, and the entry point's comment explaining why a module must never
`require('express')`. A check that cannot survive being described is one people stop writing
comments around, so it strips comments and template literals with a character walk rather than a
regexp (a URL in a string contains a comment opener; a comment contains quotes) and carries its own
test suite. The same applies to slice 4's §5.2 grep, which will be read by a codebase that discusses
modules constantly.
#### Slice 1 — the whole server half (Module-uo#3 + website#137, 2026-08-11)
40 files, ~9,674 lines, 27 of 68 tables, 25 of 82 test files. Core no longer contains anything that
knows what a shard is. Three commits per repo, readable in order.
**The acceptance criterion held exactly.** Core's `routes.manifest.json` goes 228 → 158 public
routes, and the 70 that left reappear byte-identical once the module is loaded — proved by generating
the manifest against core+module and diffing it against the pre-extraction file: zero missing, zero
added, and `routes.guards.json` identical across all 228, so no auth gate moved either.
**`server/core.js` is the port mechanism and the shape is the finding.** Ported code requires its
dependencies at file scope, which runs before `register()` and therefore before any `ctx` exists — so
every member of that file is a stable function resolving `ctx` when *called*, and nothing may be
destructured off `ctx` at init either, because core is free to hand over a getter. That kept the port
to a one-line import change per file instead of a signature change per function. Its consequence:
**require order is load-bearing.** A router does `const express = core.express` at its own file
scope, so `core.init(ctx)` must run before the first `require` under `router/`, and the module's
entry point requires its routers inside `register()` for exactly that reason.
**The contract grew to 1.1.0**, four members, none of which could be avoided:
`ctx.activity.log` (an admin action a module performs belongs in core's *one* audit log — a module
with its own is a second place to look, which means a place nobody looks), `ctx.users.getById`,
`ctx.site.baseUrl`, and `ctx.middleware.rateLimit` + `accountChangeLimiter`. The rate-limit split is
worth restating: a module states its own window and cap because it knows what its endpoints cost, and
takes the plumbing from core so there is one `express-rate-limit` in the process and one place a
breach is logged.
**`registerPostHook` is the fourth registry and the last coupling removed** — see
[API §2.4](MODULE_API.md#24-api--what-the-module-registers).
**What was vendored, and what deliberately was not.** `deriveExcerpt` came across as nine lines of
pure text handling; core's **sanitiser** sitting beside it did not, because a second copy of a
security control diverges silently the moment either is fixed. That is the line: pure leaf helpers
may be copied, controls may not.
**Two defects the extraction exposed, both in core.** The loader matched `CREATE TABLE` against the
**raw** fragment, so a schema file whose header says "every CREATE TABLE carries IF NOT EXISTS" was
rejected for a prefix violation on a table called `carries` — the same class as slice 0's boundary
check failing on its own documentation, and now fixed on both scans by reading split statements. And
the atlas art map resolved `../../../db/data`, correct in core and pointing outside `server/` in the
module: a path that happens to resolve is exactly what survives a green suite, because the
absent-file branch returns `{}` and looks like the normal case. It was caught by the integration run,
not by tests.
**One deliberate behaviour change.** `uoLinkSocket.start()` and the sidecar health probe used to run
*after* the listener bound and now run before it, because `onBoot` does. `start()` returns as soon as
the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and **not**
awaited — an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is
diagnostics; being up is not a precondition for serving a page.
**One test stayed that looked like it should move.** `playerRouteAccess.test.js` guards a real past
bug — an admin 403'd off their own characters — through a now-module-owned URL, but the *guarantee*
is core's: `/player/*` is role-agnostic self-service. It stays and asserts that through
`/player/appeals`. Moving it would have left core with no test of its own tier rule, which is
precisely what regressed once before.
**A note for anyone running core's suite locally: remove `modules/uo` first.** With a module
installed the manifest tests fail correctly — core's committed manifest is core-only, and the live
stack has the module's routes on it.
**One thing to know before running a module locally: the loader skips a *symlinked* module directory
silently.** `readdirSync(…, { withFileTypes: true }).filter(e => e.isDirectory())` reports a Windows
junction as a symlink, so a module linked rather than copied into `modules/` is simply not there,
with nothing logged. Not a defect for a real install — `modules/` is a bind mount of real
directories (§2.5) — but it is the first thing to check when a module fails to appear.
**Phase 4 — Delivery.** The admin-panel Modules screen (install, enable, disable, retry, purge,
`startup_failed` with its recorded reason) and the Docker-environment path from §2.5. Deliberately
@@ -793,3 +1082,7 @@ row for it — when it has content, not while it is an empty repo.
| 11 | Website work lands on `edge` and reaches `main` as one cutover at the end | §2.9 |
| 12 | The module repo is `RunicGateway/Module-uo`; the module id is `uo` | §2.3 |
| 13 | `RunicGateway/Integration-kit` is the module-builder's instruction book — module + sidecar + game plugin, teaching only, never re-specifying a contract | §2.11 |
| 14 | Phase 3 extracts **server-first, then client**, sliced by feature; `module-uo` merges before `website` in each pair | §2.7.1 |
| 15 | Criterion 1's grep reads **code, not prose**; core's UO copy is rewritten in its own slice instead | API §5.2, §2.7.1 |
| 16 | `module-uo`'s CI checks core out at a **pinned ref** to generate its frozen route manifest | API §5.3 |
| 17 | The `module-rust` dry run lands as `docs/modules/rust-dryrun.md`; the Integration Kit links to it | §2.7.1, §2.11 |

View File

@@ -536,10 +536,20 @@ Three existing behaviors the merge must not disturb:
no menu. `pruneNav` applies the gate inside a section and then drops one it
leaves empty.
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
redirects them out of anything else. Overrides apply before that filter, so a
moderator can still end up with a legitimately short sidebar — but the redirect
effect must keep working untouched.
- **Moderator confinement.** `AdminLayout` restricts moderators to the rows their
role carries and redirects them out of anything else. Overrides apply before
that filter, so a moderator can still end up with a legitimately short sidebar
— but the redirect effect must keep working untouched.
Amended by the module system's Phase 2 PR 8: this used to be a hardcoded
`MOD_PATHS` allowlist plus a second, differently-worded prefix check in the
redirect, and the two had drifted — `/admin/houses` was on the sidebar and not
in the redirect, so a moderator who clicked Houses was bounced to Moderation.
Both are now derived from each row's own `roles`
([`adminNav.js`](../../website/client/src/lib/adminNav.js)), and the redirect
derives from the **base** nav rather than the merged one, which is what keeps
an override from moving the boundary in either direction. See
[`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §1.4.
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
override that hides every item in a group must produce no orphaned header.