Compare commits
8 Commits
b43b40e71b
...
docs/modul
| Author | SHA1 | Date | |
|---|---|---|---|
| f3a6231084 | |||
| f402395fa5 | |||
| 7548c20820 | |||
| 749233d378 | |||
| 12e4eaad10 | |||
| 8a7b099c2d | |||
| d66ee832c5 | |||
| f740530eb8 |
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user