feat(modules): interleave module nav, derive moderator confinement
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>
This commit is contained in:
56
client/src/modules/featureGate.js
Normal file
56
client/src/modules/featureGate.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Which nav rows a viewer may see, when the answer belongs to a module.
|
||||
//
|
||||
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
|
||||
// the contract is docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// Ten of the sixteen rows in the public header carry a `feature`, and every one
|
||||
// of them is a shard surface an admin can disable or gate to a higher audience.
|
||||
// The provider that answers those questions — `useShardFeatures` — moves out
|
||||
// with the module, so core cannot keep calling it directly and still be a core.
|
||||
// It keeps a generic seam instead, and the module fills it.
|
||||
//
|
||||
// **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 `useShardFeatures` under until Phase 3 moves those rows into
|
||||
// the module and they arrive stamped `uo` instead.
|
||||
//
|
||||
// Everything here fails OPEN, and that is deliberate and unchanged from
|
||||
// useShardFeatures' own posture: this is presentation, the gate is server-side
|
||||
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
|
||||
// rendered), so an unknown answer shows the link rather than blanking the nav.
|
||||
// The one thing a UI mistake must never do here is hide a page from someone
|
||||
// entitled to it.
|
||||
|
||||
/**
|
||||
* The predicate the layouts filter their nav with.
|
||||
*
|
||||
* @param {Map<string, {has: (name: string) => boolean} | null | undefined>} flagsByOwner
|
||||
* one entry per registered provider, keyed by the id of the module that
|
||||
* registered it. The value is whatever that provider's hook returned this
|
||||
* render: a Set-like of the flags this viewer may see, or `null` while the
|
||||
* answer is still in flight.
|
||||
* @returns {(item: object) => boolean}
|
||||
*/
|
||||
export function buildFeatureGate(flagsByOwner) {
|
||||
return function isVisible(item) {
|
||||
if (!item || !item.feature) return true
|
||||
const owner = item.moduleId ?? 'core'
|
||||
// No provider for this owner: the row names a flag nothing answers for. That
|
||||
// is the no-module-installed case — no core row carries a `feature` once the
|
||||
// module is out — and it is a correct no-op rather than a hidden row.
|
||||
if (!flagsByOwner || !flagsByOwner.has(owner)) return true
|
||||
const flags = flagsByOwner.get(owner)
|
||||
// Still loading, or a provider that returned something unusable. Both are
|
||||
// "we do not know yet", and both show the link.
|
||||
if (!flags || typeof flags.has !== 'function') return true
|
||||
return flags.has(item.feature)
|
||||
}
|
||||
}
|
||||
|
||||
/** The gate an area with no providers gets: everything is visible. */
|
||||
export const OPEN_GATE = () => true
|
||||
|
||||
export default buildFeatureGate
|
||||
65
client/src/modules/features.jsx
Normal file
65
client/src/modules/features.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
import { featureProviders } from './registry.js'
|
||||
import { buildFeatureGate, OPEN_GATE } from './featureGate.js'
|
||||
|
||||
// The React half of the feature seam. The decision logic is featureGate.js,
|
||||
// which is plain JS and therefore testable in a runner with no DOM; this file is
|
||||
// wiring, the same split registry.js and shared.js already use.
|
||||
//
|
||||
// **Calling a hook per provider inside a loop is the point, and it is legal
|
||||
// here.** The rules of hooks require the same hooks in the same order on every
|
||||
// render of a component — not a statically known list. The provider list is
|
||||
// fixed before the first render (registration happens while module chunks
|
||||
// evaluate, and main.jsx does not mount until DOMContentLoaded), there is no
|
||||
// unregistering, and the snapshot below freezes it per component instance
|
||||
// anyway. So the loop's length cannot change between renders of this provider,
|
||||
// which is the actual requirement.
|
||||
//
|
||||
// A provider hook returns a Set-like of the flags this viewer may see, or `null`
|
||||
// while it is still fetching. Core knows nothing else about it: what a flag
|
||||
// means, how it is fetched, and what it is gated on are all the module's.
|
||||
|
||||
const FeatureGateContext = createContext(OPEN_GATE)
|
||||
|
||||
export function ModuleFeaturesProvider({ children }) {
|
||||
// Snapshotted once. useState's initialiser runs on the first render only, so
|
||||
// even a provider that somehow registered late cannot change this instance's
|
||||
// hook count mid-life — it would be ignored until the next mount, which is a
|
||||
// far better failure than a crashed render.
|
||||
const [providers] = useState(featureProviders)
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above
|
||||
const values = providers.map((provider) => provider.hook())
|
||||
|
||||
const gate = useMemo(
|
||||
() => {
|
||||
const byOwner = new Map()
|
||||
// First registration wins for a given owner: a module that registers two
|
||||
// namespaces answers its own nav rows from the first, rather than from
|
||||
// whichever happened to be stored last.
|
||||
providers.forEach((provider, i) => {
|
||||
if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i])
|
||||
})
|
||||
return buildFeatureGate(byOwner)
|
||||
},
|
||||
// One dependency per provider — a fixed-length list, for the same reason the
|
||||
// hook loop above is fixed-length.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[providers, ...values],
|
||||
)
|
||||
|
||||
return <FeatureGateContext.Provider value={gate}>{children}</FeatureGateContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* The predicate to filter nav rows with: `(item) => boolean`, true when the row
|
||||
* carries no `feature` or when its module says this viewer may see it.
|
||||
*
|
||||
* Outside a provider it is the open gate, so a component rendered in isolation
|
||||
* (a test, a preview) shows its whole nav rather than none of it.
|
||||
*/
|
||||
export function useFeatureGate() {
|
||||
return useContext(FeatureGateContext)
|
||||
}
|
||||
|
||||
export default ModuleFeaturesProvider
|
||||
174
client/src/modules/nav.js
Normal file
174
client/src/modules/nav.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// The interleave of module nav items into core's nav.
|
||||
//
|
||||
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
|
||||
// the normative contract is docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// **Module items join the BASE array, before anything else happens to it.** That
|
||||
// is the whole design of this file and the override merge next door forces it:
|
||||
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
|
||||
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
|
||||
// route cannot leave a stale row doing something unexpected later). Append
|
||||
// module items *after* that merge and they are unreachable to Admin →
|
||||
// Navigation: unorderable, unrelabellable, unhideable. 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 touched their nav.
|
||||
//
|
||||
// So the pipeline gains one step at the front and nothing else changes:
|
||||
//
|
||||
// withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
|
||||
//
|
||||
// and the filter stays last, which is what keeps it the boundary an override
|
||||
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
|
||||
// the other way round; the code is right and the contract was amended.
|
||||
//
|
||||
// The result is that a module row is, to everything downstream, an ordinary row.
|
||||
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.
|
||||
|
||||
import { navFor } from './registry.js'
|
||||
import { isGrouped } from '../lib/navOverrides.js'
|
||||
|
||||
// Rows with no group of their own are collected under this key. A Symbol rather
|
||||
// than a string so it cannot collide with a group an admin or a module names.
|
||||
const UNGROUPED = Symbol('ungrouped')
|
||||
|
||||
/**
|
||||
* Sort by effective position, where a row that asked for nothing keeps the index
|
||||
* it already had. Three tie-breaks, in this order: an explicit `order` beats a
|
||||
* coincidental index (the module said "third", so third), and two explicit
|
||||
* orders keep registration order, which `navFor` has already put in scan order.
|
||||
*
|
||||
* The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
|
||||
* admin who then drags that row is editing the position this produced.
|
||||
*/
|
||||
function place(entries) {
|
||||
return entries
|
||||
.map((entry, index) => ({ ...entry, index }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
|
||||
.map(({ item }) => item)
|
||||
}
|
||||
|
||||
function entryFor(item, fallbackKey) {
|
||||
return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
|
||||
}
|
||||
|
||||
function coreEntries(items) {
|
||||
return items.map((item, index) => ({ item, key: index, explicit: false }))
|
||||
}
|
||||
|
||||
/** The `to`s a base nav already claims, flat or grouped. */
|
||||
function claimedPaths(baseNav, grouped) {
|
||||
return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a module row whose `to` is already on the nav, and say so.
|
||||
*
|
||||
* Not a policy about where a module may link — it is that `to` is the KEY the
|
||||
* override layer stores under and React renders by. Two rows sharing one would
|
||||
* give an admin a single editor row that silently moves both, and a duplicate
|
||||
* key in the rendered list. Dropping the newcomer keeps core's row, which is the
|
||||
* one any existing override was written against.
|
||||
*
|
||||
* Fail-safe like every other read in this area: the offending row goes, its
|
||||
* neighbours stay.
|
||||
*/
|
||||
function withoutCollisions(items, claimed) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
if (!item || typeof item.to !== 'string' || !item.to) continue
|
||||
if (claimed.has(item.to)) {
|
||||
console.warn(
|
||||
`[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
claimed.add(item.to)
|
||||
out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The flat navs — the public header and the player portal.
|
||||
//
|
||||
// No groups, so `order` is a position in the one list: core rows are keyed by
|
||||
// their index and a module row by the `order` it asked for. A module row with no
|
||||
// order appends after the coded ones, in registration order, rather than jumping
|
||||
// to the front on a 0 default — the same choice buildPublicNav makes for an
|
||||
// admin-created link.
|
||||
function mergeFlat(baseNav, items) {
|
||||
return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
|
||||
}
|
||||
|
||||
// The grouped nav — the admin sidebar.
|
||||
//
|
||||
// `group` names an existing core group and the row lands inside it: Moderation
|
||||
// and System, where today's UO rows already sit (§1.4). An unknown group name
|
||||
// creates a group at the end rather than dropping the row — a typo must cost a
|
||||
// position, never a link. A row with no `group` at all lands in a trailing
|
||||
// untitled group, which renders as ungrouped links; core does not invent a
|
||||
// display title out of a module id.
|
||||
//
|
||||
// An ungrouped row is NOT folded into one of core's own untitled groups
|
||||
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
|
||||
// the sidebar, and a module page does not belong beside "Account".
|
||||
//
|
||||
// A group created here is a group as far as everything downstream is concerned,
|
||||
// including as a destination in Admin → Navigation's "move to section" control:
|
||||
// `readOverrides` builds its set of legal destinations from the base nav it is
|
||||
// handed, which is this one.
|
||||
function mergeGrouped(baseNav, items) {
|
||||
const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
|
||||
const into = new Map() // existing group title → rows
|
||||
const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order
|
||||
|
||||
for (const item of items) {
|
||||
const named = typeof item.group === 'string' && item.group ? item.group : null
|
||||
const key = named ?? UNGROUPED
|
||||
const bucket = named !== null && titles.has(named) ? into : fresh
|
||||
if (!bucket.has(key)) bucket.set(key, [])
|
||||
bucket.get(key).push(item)
|
||||
}
|
||||
|
||||
const kept = baseNav.map((g) => {
|
||||
const incoming = into.get(g.title)
|
||||
if (!incoming) return g
|
||||
return {
|
||||
...g,
|
||||
items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
|
||||
}
|
||||
})
|
||||
|
||||
const created = [...fresh.entries()].map(([key, rows]) => {
|
||||
const items_ = place(rows.map((item, i) => entryFor(item, i)))
|
||||
return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
|
||||
})
|
||||
|
||||
return [...kept, ...created]
|
||||
}
|
||||
|
||||
/**
|
||||
* The base nav a layout should render: core's coded array with every installed
|
||||
* module's rows for this area interleaved into it.
|
||||
*
|
||||
* Returns `baseNav` ITSELF when no module registered anything for this area, so
|
||||
* an instance with no modules installed renders the identical array it renders
|
||||
* today — the same "untouched path" guarantee applyNavOverrides makes, and what
|
||||
* makes a `useMemo` with an empty dependency list around this call honest.
|
||||
*
|
||||
* Safe to call once per component and cache: registration completes before the
|
||||
* first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
|
||||
* there is no unregistering, so this answer cannot change during a session.
|
||||
*
|
||||
* @param {Array} baseNav the coded NAV, flat or grouped
|
||||
* @param {'public'|'admin'|'player'} area
|
||||
* @returns {Array} a nav of the same shape
|
||||
*/
|
||||
export function withModuleNav(baseNav, area) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
const grouped = isGrouped(baseNav)
|
||||
const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
|
||||
if (items.length === 0) return baseNav
|
||||
return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
|
||||
}
|
||||
|
||||
export default withModuleNav
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const featureProviders = new Map()
|
||||
const providers = new Map()
|
||||
const registered = new Set()
|
||||
|
||||
const AREAS = ['public', 'admin', 'player']
|
||||
@@ -96,7 +96,7 @@ export function registerNav(id, spec) {
|
||||
* carries a `feature` today.
|
||||
*/
|
||||
export function registerFeatureProvider(id, namespace, hook) {
|
||||
featureProviders.set(namespace, { id, hook })
|
||||
providers.set(namespace, { id, hook })
|
||||
registered.add(id)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,19 @@ export const routesFor = (area) => routes[area] || []
|
||||
export const navFor = (area) =>
|
||||
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
|
||||
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
|
||||
export const featureProviderFor = (namespace) => providers.get(namespace)
|
||||
|
||||
/**
|
||||
* Every registered provider, for core's feature context to call.
|
||||
*
|
||||
* Exported from the module but deliberately NOT a member of the `registry`
|
||||
* object below: a module asks for a namespace it knows the name of, and has no
|
||||
* business enumerating what everyone else registered. Core needs the list
|
||||
* because it has to call each hook — unconditionally, in a fixed order, at the
|
||||
* top of a component (modules/features.jsx).
|
||||
*/
|
||||
export const featureProviders = () =>
|
||||
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
|
||||
|
||||
export const registeredIds = () => [...registered]
|
||||
|
||||
@@ -118,7 +130,7 @@ export function _reset() {
|
||||
routes[area].length = 0
|
||||
nav[area].length = 0
|
||||
}
|
||||
featureProviders.clear()
|
||||
providers.clear()
|
||||
registered.clear()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user