feat(modules): interleave module nav items and derive moderator confinement (phase 2, PR 8) #135

Merged
whitlocktech merged 1 commits from feature/module-nav-interleave into edge 2026-08-11 05:29:42 +00:00
Member

Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 — the nav half PR 7 deferred, plus the two seams §1.4 and §1.5 asked for. Docs PR: RunicGateway/docs#133.

Client-only. 933 server tests (unchanged), 160 client tests (+37). routes.manifest.json unchanged at 230 routes; the OpenAPI spec regenerates byte-identical.

The interleave happens before the override merge

withModuleNav (client/src/modules/nav.js) merges an installed module's rows into core's three navs, and it runs first. That ordering is the design, not an implementation detail: applyNavOverrides and buildPublicNav are keyed by to and drop any key their base array does not declare, so rows appended after the merge would be unorderable, unrelabellable and unhideable in Admin → Navigation. 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.

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

Six details it settled, all in MODULE_API.md §3.3: an unordered row appends rather than defaulting to 0; an ungrouped admin row gets its own trailing group rather than joining core's Dashboard/Account furniture; an unknown group name creates a group rather than dropping the row; a module-created group is itself a legal override destination; a colliding to is dropped with a warning (it is the key the override layer stores under); and feature applies in all three areas, not just the public one.

MOD_PATHS is gone, and it took a defect with it

Moderator visibility and the redirect that confines a moderator both derive from each row's own roles, in the new plain-JS client/src/lib/adminNav.js — plain so the DOM-less test runner can reach what is the closest thing in the client to an authorization decision.

Two rows move, both 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.

The defect predates the module system: the redirect was a third hardcoded list — three path prefixes against MOD_PATHS' five paths — and they disagreed about /admin/houses. A moderator who clicked Houses in their own sidebar was bounced straight back to Moderation. The derived allow-list is computed from the base nav, never the override-merged one: an override is presentation and must not move an authorization boundary in either direction.

The feature seam

modules/features.jsx + modules/featureGate.js. A row's feature is resolved by the provider its own module registered, so the namespace comes from the registration and no flag name carries a parsed prefix — a module author writes feature: 'status' exactly as it reads today.

Core registers useShardFlags under the owner id core (the client twin of the server's registries.registerCore()), so the ten shard-gated header rows already run through the seam rather than beside it, and Phase 3 deletes a registration instead of rewriting SiteHeader under extraction pressure.

Every unknown fails open — no provider, a null answer while the fetch is in flight, or a provider that returned something without a has all show the link. The server is the gate; hiding a page from someone entitled to it is the worse mistake.

Verification

The §7.7 browser smoke was re-run, since this is the seam that rule exists for. A throwaway module registering nav in all three areas plus a provider granting one flag and withholding another, in Chrome with the console open:

  • the module row lands inside core's Moderation group, not in an appended block;
  • the withheld row does not render, the granted one does;
  • a moderator reaches /admin/houses and the module's own admin page, and is still redirected off /admin/users;
  • an admin relabels a module row in Admin → Navigation, saves, and the public header shows the new label after a reload — the whole point of merging before the override layer;
  • the module's admin page titles from its nav row rather than falling through to a bare "Admin".

Zero CSP reports, zero console errors.

AI disclosure

  • This contribution was AI-assisted (Claude Code). Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.
Phase 2, PR 8 of [`docs/website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md) §2.7 — the nav half PR 7 deferred, plus the two seams §1.4 and §1.5 asked for. Docs PR: RunicGateway/docs#133. Client-only. **933 server tests** (unchanged), **160 client tests** (+37). `routes.manifest.json` unchanged at 230 routes; the OpenAPI spec regenerates byte-identical. ## The interleave happens *before* the override merge `withModuleNav` (`client/src/modules/nav.js`) merges an installed module's rows into core's three navs, and it runs first. That ordering is the design, not an implementation detail: `applyNavOverrides` and `buildPublicNav` are keyed by `to` and drop any key their base array does not declare, so rows appended *after* the merge would be unorderable, unrelabellable and unhideable in Admin → Navigation. 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. Merging 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. Six details it settled, all in `MODULE_API.md` §3.3: an unordered row appends rather than defaulting to 0; an ungrouped admin row gets its own trailing group rather than joining core's Dashboard/Account furniture; an unknown group name creates a group rather than dropping the row; a module-created group is itself a legal override destination; a colliding `to` is dropped with a warning (it is the key the override layer stores under); and `feature` applies in all three areas, not just the public one. ## `MOD_PATHS` is gone, and it took a defect with it Moderator visibility and the redirect that confines a moderator both derive from each row's own `roles`, in the new plain-JS `client/src/lib/adminNav.js` — plain so the DOM-less test runner can reach what is the closest thing in the client to an authorization decision. Two rows move, both 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. **The defect** predates the module system: the redirect was a *third* hardcoded list — three path prefixes against `MOD_PATHS`' five paths — and they disagreed about `/admin/houses`. A moderator who clicked Houses **in their own sidebar** was bounced straight back to Moderation. The derived allow-list is computed from the **base** nav, never the override-merged one: an override is presentation and must not move an authorization boundary in either direction. ## The feature seam `modules/features.jsx` + `modules/featureGate.js`. A row's `feature` is resolved by the provider **its own module** registered, so the namespace comes from the registration and no flag name carries a parsed prefix — a module author writes `feature: 'status'` exactly as it reads today. Core registers `useShardFlags` under the owner id `core` (the client twin of the server's `registries.registerCore()`), so the ten shard-gated header rows already run through the seam rather than beside it, and Phase 3 deletes a registration instead of rewriting `SiteHeader` under extraction pressure. Every unknown **fails open** — no provider, a `null` answer while the fetch is in flight, or a provider that returned something without a `has` all show the link. The server is the gate; hiding a page from someone entitled to it is the worse mistake. ## Verification The §7.7 browser smoke was re-run, since this is the seam that rule exists for. A throwaway module registering nav in all three areas plus a provider granting one flag and withholding another, in Chrome with the console open: - the module row lands **inside** core's Moderation group, not in an appended block; - the withheld row does not render, the granted one does; - a moderator reaches `/admin/houses` **and** the module's own admin page, and is still redirected off `/admin/users`; - an admin relabels a module row in Admin → Navigation, saves, and the public header shows the new label after a reload — the whole point of merging before the override layer; - the module's admin page titles from its nav row rather than falling through to a bare "Admin". Zero CSP reports, zero console errors. ## AI disclosure - [x] This contribution was AI-assisted (Claude Code). Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`.
wtclaude added 1 commit 2026-08-11 04:43:01 +00:00
feat(modules): interleave module nav, derive moderator confinement
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 1m34s
PR Checks / client-build (pull_request) Successful in 8m58s
a45a3d120a
Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md 2.7 - the nav half PR 7
deferred, plus the two seams 1.4 and 1.5 asked for.

withModuleNav (client/src/modules/nav.js) merges an installed module's rows
into core's three navs BEFORE the admin-override merge, and that ordering is
the design. applyNavOverrides and buildPublicNav are keyed by `to` and drop
any key their base array does not declare, so rows appended after the merge
would be unorderable, unrelabellable and unhideable in Admin - Navigation.
Today's UO rows are all three of those things, so appending would make the
extraction a visible regression for anyone who has ever edited their nav.
Merging first means a module row is an ordinary row downstream: nothing in
navOverrides.js, NavEditor.jsx or the layouts knows a module exists.

MOD_PATHS is gone. Moderator visibility and the redirect that confines a
moderator both derive from each row's own `roles`, in the new plain-JS
lib/adminNav.js (plain so the DOM-less runner can reach it). Two rows move,
both toward what the server already permitted: Dashboard, whose roles had
always named moderator, and My Characters, which is ungated self-service.

That also fixes a defect predating the module system. The redirect was a
THIRD hardcoded list - three path prefixes against MOD_PATHS' five paths -
and they disagreed 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, never the override-merged one: an override is
presentation and must not move an authorization boundary either way.

The feature seam (modules/features.jsx + modules/featureGate.js) resolves a
row's `feature` against the provider its OWN module registered, so the
namespace comes from the registration and no string carries a parsed prefix.
Core registers useShardFlags under the owner id `core` - the client twin of
registries.registerCore() - so the ten shard-gated header rows already run
through the seam and Phase 3 deletes a registration instead of rewriting
SiteHeader. Every unknown fails open: no provider, a null answer while a
fetch is in flight, or a junk return all show the link, because the server is
the gate and hiding a page from someone entitled to it is the worse mistake.

933 server tests (unchanged - this PR is client-only), 160 client tests
(+37). routes.manifest.json unchanged at 230 routes; the OpenAPI spec
regenerates byte-identical.

Re-ran the MODULE_API.md 7.7 browser smoke, 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: the row lands inside
core's Moderation group rather than an appended block, the withheld row does
not render, a moderator reaches both /admin/houses and the module's admin
page, and an admin can relabel a module row and have it persist and apply.
Zero CSP reports, zero console errors.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 0a3f1eb9fa into edge 2026-08-11 05:29:42 +00:00
whitlocktech deleted branch feature/module-nav-interleave 2026-08-11 05:29:43 +00:00
Sign in to join this conversation.
No description provided.