Merge pull request 'feat(modules): interleave module nav items and derive moderator confinement (phase 2, PR 8)' (#135) from feature/module-nav-interleave into edge

Reviewed-on: #135
This commit is contained in:
2026-08-11 05:29:40 +00:00
17 changed files with 1119 additions and 228 deletions

View File

@@ -6,6 +6,7 @@ import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx' import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx' import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js' import { routesFor } from './modules/registry.js'
import { ModuleFeaturesProvider } from './modules/features.jsx'
// Public // Public
import Portal from './routes/public/Portal.jsx' import Portal from './routes/public/Portal.jsx'
@@ -80,6 +81,13 @@ export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<SiteProvider> <SiteProvider>
{/* Inside the auth and site contexts, because a feature provider is a
hook that may well read either — the shard one does, indirectly, by
asking an endpoint whose answer depends on the session. Outside the
routes, so the nav in every layout is filtered by the same gate and
the provider hooks are called once for the whole app rather than
once per screen. */}
<ModuleFeaturesProvider>
<Routes> <Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is {/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the itself the pre-launch "coming soon" page, so it sits outside the
@@ -264,6 +272,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</ModuleFeaturesProvider>
</SiteProvider> </SiteProvider>
</AuthProvider> </AuthProvider>
) )

View File

@@ -4,22 +4,28 @@ import MoonDot from './MoonDot.jsx'
import BrandLogo from './BrandLogo.jsx' import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
import NavDropdown from './NavDropdown.jsx' import NavDropdown from './NavDropdown.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js' import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { parseJsonSetting } from '../lib/settingsJson.js' import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js'
import { useFeatureGate } from '../modules/features.jsx'
// One consistent top nav for the whole public site. Every page gets the same // One consistent top nav for the whole public site. Every page gets the same
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin). // main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
// //
// Entries carrying a `feature` are shard surfaces an admin can disable or gate // Entries carrying a `feature` are surfaces an admin can disable or gate to a
// to a higher audience (Admin -> Shard Visibility). They are hidden when this // higher audience (Admin -> Shard Visibility). They are hidden when this viewer
// viewer can't reach them, so we never render a link that would 403. The gate // can't reach them, so we never render a link that would 403. The gate itself is
// itself is server-side; this is only about not advertising a dead end. // server-side; this is only about not advertising a dead end. Which module
// answers for a given flag is the registry's business now, not this file's —
// core registers `useShardFlags` for the ten below and Phase 3 hands them over
// (modules/featureGate.js).
// //
// Exported because Admin -> Navigation edits this list. It stays declared here, // Exported because Admin -> Navigation edits this list. It stays declared here,
// with this component as its owner: the editor may only relabel, reorder and // with this component as its owner: the editor may only relabel, reorder and
// hide what it finds, and `to`/`feature` are never its to change (§7). // hide what it finds, and `to`/`feature` are never its to change (§7). An
// installed module's rows join it in `withModuleNav` below — before the override
// merge, so an admin can edit those rows exactly as they edit these.
export const NAV = [ export const NAV = [
{ label: 'Home', to: '/', end: true }, { label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' }, { label: 'News', to: '/site/news' },
@@ -48,7 +54,12 @@ const linkStyle = ({ isActive }) => ({
export default function SiteHeader() { export default function SiteHeader() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const { siteTitle, settings } = useSite() const { siteTitle, settings } = useSite()
const shardFeatures = useShardFeatures() const isVisible = useFeatureGate()
// Core's rows plus every installed module's. Computed once: the registry is
// fixed before the first render and there is no unregistering, so this cannot
// change during a session (modules/nav.js).
const baseNav = useMemo(() => withModuleNav(NAV, 'public'), [])
// An admin may relabel, reorder and hide these entries from Admin → // An admin may relabel, reorder and hide these entries from Admin →
// Navigation, and may group them into dropdown sections alongside links of // Navigation, and may group them into dropdown sections alongside links of
@@ -62,9 +73,9 @@ export default function SiteHeader() {
// • with no stored row this is the coded NAV, in code order, so an // • with no stored row this is the coded NAV, in code order, so an
// untouched instance renders exactly what it renders today. // untouched instance renders exactly what it renders today.
const nav = useMemo(() => { const nav = useMemo(() => {
const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public)) const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature)) return pruneNav(tree, isVisible)
}, [settings.nav_public, shardFeatures]) }, [baseNav, settings.nav_public, isVisible])
// Where the auth entry points: staff → admin, player → portal, else sign in. // Where the auth entry points: staff → admin, player → portal, else sign in.
let account let account

View File

@@ -0,0 +1,64 @@
// Who may see a row of the admin sidebar, and where that lets them go.
//
// Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders
// by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md
// §8.1), and a second copy of this answer is exactly the thing this file exists
// to abolish. And it is the closest thing in the client to an authorization
// decision, so it belongs somewhere the test runner can reach, which a .jsx file
// is not.
//
// **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was
// `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined
// moderators, AND a third prefix list in the redirect effect that disagreed with
// both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be
// added to a list core hardcodes, which is what forced the derivation — but the
// lists had already drifted from each other without a module in sight.
/**
* Can a viewer with this role see this row?
*
* Applied AFTER the override merge in both callers: an override is presentation
* and this is the boundary, so an override saying `hidden: false` on a row this
* role cannot see still shows nothing (THEMING_AND_NAV.md §7).
*
* A row with no `roles` is visible to everyone who reached the admin area at
* all — that is the self-service case (Account, My Characters), and staff are a
* superset of players.
*/
export function navItemVisibleTo(item, role) {
return !item.roles || item.roles.includes(role)
}
/**
* The paths a viewer with this role may reach, derived from the rows they see.
*
* Takes the BASE nav, never the override-merged one: an override must not be
* able to move this boundary in either direction. Hiding a row from a
* moderator's sidebar must not also bar them from the page behind it, and
* un-hiding one must not admit them to a page their role does not carry.
*
* @param {Array<{items: Array}>} baseNav the grouped admin nav
* @param {string} role
* @returns {Array<{to: string, exact: boolean}>}
*/
export function allowedPathsFor(baseNav, role) {
return (Array.isArray(baseNav) ? baseNav : [])
.flatMap((g) => g.items || [])
.filter((item) => navItemVisibleTo(item, role))
.map((item) => ({ to: item.to, exact: item.end === true }))
}
/**
* Is this pathname one of them?
*
* A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix
* of the whole admin area, and treating it as one would let every path through.
* Every other row also covers its sub-routes, which is what keeps
* `/admin/moderation/appeals/12` and a module's detail pages reachable without
* anyone listing them.
*/
export function isAllowedPath(pathname, allowed) {
return (allowed || []).some(({ to, exact }) =>
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
)
}

View File

@@ -20,7 +20,10 @@
// Two shapes are supported, because two exist: // Two shapes are supported, because two exist:
// flat [{ to, label, ... }] — public header, player portal // flat [{ to, label, ... }] — public header, player portal
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar // grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
function isGrouped(nav) { // Exported for modules/nav.js, which has to answer the same question about the
// same array a moment earlier — one implementation, so the interleave and the
// merge can never disagree about which shape they are looking at.
export function isGrouped(nav) {
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items)) return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
} }

View File

@@ -56,3 +56,15 @@ export function useShardFeatures() {
export function canSee(features, name) { export function canSee(features, name) {
return !features || features.set.has(name) return !features || features.set.has(name)
} }
// The same answer in the shape core's generic feature seam takes: a Set-like of
// the flags this viewer may see, or null while we do not know yet
// (modules/featureGate.js). Core registers THIS as the provider for the `uo`
// namespace (main.jsx), so the ten shard-gated rows in the public header are
// already resolved through the module seam rather than beside it — when Phase 3
// moves those rows into the module, the registration moves with this file and
// core is left with nothing to delete.
export function useShardFlags() {
const features = useShardFeatures()
return features ? features.set : null
}

View File

@@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js' import { publishSharedDependencies } from './modules/shared.js'
import { registerFeatureProvider } from './modules/registry.js'
import { useShardFlags } from './lib/useShardFeatures.js'
import './styles/theme.css' import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates. // Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -13,6 +15,19 @@ import './styles/theme.css'
// (docs/website/MODULE_API.md §3.2). // (docs/website/MODULE_API.md §3.2).
publishSharedDependencies() publishSharedDependencies()
// Core registers through the same seam a module uses, and registers FIRST — the
// client twin of the server's `registries.registerCore()` (MODULE_SYSTEM.md
// §1.9). The ten shard-gated rows in the public header are core's only because
// Phase 3 has not moved them yet; routing them through the registry now means
// SiteHeader holds one mechanism instead of two, and the extraction becomes a
// deletion rather than a rewrite made under extraction pressure.
//
// The owner id is `core`, which is what a nav row with no `moduleId` resolves
// against (modules/featureGate.js). The namespace is `uo`, so a module that
// wants to read these flags — the `uo` module itself, once it owns them — asks
// for them by the name they will always have had.
registerFeatureProvider('core', 'uo', useShardFlags)
// Render on DOMContentLoaded rather than immediately, and that is the one line // Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes. // of core's boot the module system changes.
// //

View 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

View 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
View 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

View File

@@ -31,7 +31,7 @@
const routes = { public: [], admin: [], player: [] } const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] } const nav = { public: [], admin: [], player: [] }
const featureProviders = new Map() const providers = new Map()
const registered = new Set() const registered = new Set()
const AREAS = ['public', 'admin', 'player'] const AREAS = ['public', 'admin', 'player']
@@ -96,7 +96,7 @@ export function registerNav(id, spec) {
* carries a `feature` today. * carries a `feature` today.
*/ */
export function registerFeatureProvider(id, namespace, hook) { export function registerFeatureProvider(id, namespace, hook) {
featureProviders.set(namespace, { id, hook }) providers.set(namespace, { id, hook })
registered.add(id) registered.add(id)
} }
@@ -108,7 +108,19 @@ export const routesFor = (area) => routes[area] || []
export const navFor = (area) => export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) [...(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] export const registeredIds = () => [...registered]
@@ -118,7 +130,7 @@ export function _reset() {
routes[area].length = 0 routes[area].length = 0
nav[area].length = 0 nav[area].length = 0
} }
featureProviders.clear() providers.clear()
registered.clear() registered.clear()
} }

View File

@@ -6,6 +6,9 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js' import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js' import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon. // Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
// One shared frame keeps them terse; each item just supplies its path(s). // One shared frame keeps them terse; each item just supplies its path(s).
@@ -104,10 +107,6 @@ export const NAV = [
const COLLAPSE_KEY = 'admin.nav.collapsed' const COLLAPSE_KEY = 'admin.nav.collapsed'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
// The one row an override may never hide: the nav editor itself, which is the // The one row an override may never hide: the nav editor itself, which is the
// only screen that can un-hide anything. The write path already refuses it // only screen that can un-hide anything. The write path already refuses it
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled — // (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
@@ -123,15 +122,11 @@ function keepEditorReachable(overrides) {
return { ...overrides, [UNHIDEABLE]: rest } return { ...overrides, [UNHIDEABLE]: rest }
} }
// Who may see a sidebar row. The single authority for that question: the layout // Who may see a sidebar row, and where that lets them go, both derived from the
// applies it after the override merge (overrides are presentation, this is the // row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an // lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
// admin is never offered a row they cannot themselves see (§8.1). // because Admin -> Navigation has always imported it from here.
export function navItemVisibleTo(item, role) { export { navItemVisibleTo }
if (item.roles && !item.roles.includes(role)) return false
if (role === 'moderator') return MOD_PATHS.includes(item.to)
return true
}
const TITLES = { const TITLES = {
'/admin': 'Dashboard', '/admin': 'Dashboard',
@@ -159,6 +154,19 @@ const TITLES = {
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
} }
// An installed module's admin pages are not in TITLES and cannot be — core does
// not know what they are called. Their nav row does, so the row is the title:
// the longest matching module row wins, so a detail page under a section titles
// as that section rather than falling through to a bare "Admin". Restricted to
// rows a module registered, which is what keeps every core path resolving
// through TITLES and sectionTitle exactly as it does today.
function moduleTitle(baseNav, pathname) {
return baseNav
.flatMap((g) => g.items)
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map. // Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) { function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation' if (pathname.startsWith('/admin/moderation')) return 'Moderation'
@@ -186,7 +194,15 @@ export default function AdminLayout() {
const navOverrides = useNavOverrides() const navOverrides = useNavOverrides()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = TITLES[location.pathname] || sectionTitle(location.pathname) const isVisible = useFeatureGate()
// Core's rows plus every installed module's, before the override merge sees
// them — so a module row is editable in Admin -> Navigation like any other
// (modules/nav.js). Computed once: the registry is fixed before the first
// render and nothing unregisters.
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
const title =
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
// The hero canvas editor needs room — let it use the full content width. // The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero' const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
@@ -200,11 +216,17 @@ export default function AdminLayout() {
// NAV itself and this is exactly the code that ran before the feature. // NAV itself and this is exactly the code that ran before the feature.
const navGroups = useMemo( const navGroups = useMemo(
() => () =>
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin)) applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) })) .map((g) => ({
...g,
// `isVisible` is a no-op for every core row — none carries a `feature`
// — and is applied here so that a module row which does carry one is
// gated on the sidebar rather than silently advertised.
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
}))
// Drop any now-empty group so an empty category header never renders. // Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0), .filter((g) => g.items.length > 0),
[navOverrides.nav_admin, user?.role], [baseNav, navOverrides.nav_admin, user?.role, isVisible],
) )
// Accordion: track which titled categories are collapsed. Persist across // Accordion: track which titled categories are collapsed. Persist across
@@ -231,17 +253,22 @@ export default function AdminLayout() {
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to))) g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
)?.title )?.title
// Where a moderator may go, from the same `roles` that decide what they see.
// It used to be a third hardcoded list — a prefix check over three paths —
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
// was on the sidebar and not in the redirect, so a moderator who clicked
// Houses in their own nav was bounced straight back to Moderation. One
// derivation cannot disagree with itself, which is the point of deriving it.
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
// Confine a moderator who deep-links (or is redirected to the index) to a page // Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home. // outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => { useEffect(() => {
if (!isModerator) return if (!isModerator) return
const p = location.pathname if (!isAllowedPath(location.pathname, allowed)) {
const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true }) navigate('/admin/moderation', { replace: true })
} }
}, [isModerator, location.pathname, navigate]) }, [isModerator, location.pathname, navigate, allowed])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt). // Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => { useEffect(() => {

View File

@@ -13,7 +13,8 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx' import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js' import { withModuleNav } from '../../../modules/nav.js'
import { useFeatureGate } from '../../../modules/features.jsx'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js' import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx' import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js' import { parseJsonSetting } from '../../../lib/settingsJson.js'
@@ -244,7 +245,7 @@ export function Row({ row, id, destinations, destination, onDestination, onChang
export default function NavEditor() { export default function NavEditor() {
const { user } = useAuth() const { user } = useAuth()
const { refresh: refreshSite } = useSite() const { refresh: refreshSite } = useSite()
const shardFeatures = useShardFeatures() const isVisible = useFeatureGate()
const [tab, setTab] = useState('nav_public') const [tab, setTab] = useState('nav_public')
// Per nav: the editable groups, the overrides as loaded (so a row this admin // Per nav: the editable groups, the overrides as loaded (so a row this admin
// cannot see survives their save), and whether a settings row exists at all. // cannot see survives their save), and whether a settings row exists at all.
@@ -255,25 +256,39 @@ export default function NavEditor() {
const [saved, setSaved] = useState('') const [saved, setSaved] = useState('')
const [dirty, setDirty] = useState({}) const [dirty, setDirty] = useState({})
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
// public nav's gates are the shard-feature ones; the admin nav's are roles.
// The player portal has no gates at all.
// The nav as coded, unfiltered. The palette below is what this admin may EDIT; // The nav as coded, unfiltered. The palette below is what this admin may EDIT;
// this is what still EXISTS, and the two are different questions. Saving needs // this is what still EXISTS, and the two are different questions. Saving needs
// both: an entry for a row their palette filtered out must be carried through // both: an entry for a row their palette filtered out must be carried through
// rather than reset, and only an entry for a route the code no longer declares // rather than reset, and only an entry for a route the code no longer declares
// at all should be dropped. // at all should be dropped.
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV } //
// Each nav is the coded array with every installed module's rows already
// interleaved (modules/nav.js) — the same array the layout renders, which is
// what makes a module row editable here at all: the override merge is keyed by
// `to` and drops a key the base it is handed does not declare, so a nav built
// from core alone would silently discard every stored override on a module row
// the moment it was saved.
const fullNavs = useMemo(
() => ({
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
nav_player: withModuleNav(PLAYER_NAV, 'player'),
}),
[],
)
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
// gates, and neither is core's own opinion any more — `roles` on a row, and
// the owning module's answer for a row that names a `feature`.
const palettes = useMemo( const palettes = useMemo(
() => ({ () => ({
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)), nav_public: fullNavs.nav_public.filter(isVisible),
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter( nav_admin: fullNavs.nav_admin
(g) => g.items.length > 0, .map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
), .filter((g) => g.items.length > 0),
nav_player: PLAYER_NAV, nav_player: fullNavs.nav_player.filter(isVisible),
}), }),
[shardFeatures, user?.role], [fullNavs, isVisible, user?.role],
) )
useEffect(() => { useEffect(() => {

View File

@@ -6,6 +6,8 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js' import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js' import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
// Shared shell for the logged-in player portal. Uses the same sidebar shell as // Shared shell for the logged-in player portal. Uses the same sidebar shell as
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in // Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
@@ -35,9 +37,10 @@ const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon> const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here; // Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No row // the editor may only relabel, reorder and hide what it finds (§7). No CORE row
// carries a gate — every player sees all three — so the merged result is what // carries a gate — every player sees all three — but an installed module's rows
// renders, with no filter after it. // join this list before the merge and may carry a `feature`, so the filter after
// it is not dead code.
export const NAV = [ export const NAV = [
{ to: '/player', label: 'Characters', end: true, icon: IconUser }, { to: '/player', label: 'Characters', end: true, icon: IconUser },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield }, { to: '/account/appeals', label: 'Appeals', icon: IconShield },
@@ -69,7 +72,12 @@ export default function PlayerPortalLayout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { siteTitle } = useSite() const { siteTitle } = useSite()
const navOverrides = useNavOverrides() const navOverrides = useNavOverrides()
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player]) const isVisible = useFeatureGate()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const nav = useMemo(
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
[baseNav, navOverrides.nav_player, isVisible],
)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = const title =

View File

@@ -0,0 +1,125 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../src/lib/adminNav.js'
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
// drifted apart from each other, so the tests worth having are the ones that
// pin what a moderator may now see and reach, and the shape of the match.
// The real sidebar, trimmed to the rows that decide something here.
const NAV = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
{
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', roles: ['admin', 'moderator'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/characters', label: 'My Characters' },
{ to: '/admin/account', label: 'Account' },
],
},
]
const visibleTo = (role) =>
NAV.flatMap((g) => g.items)
.filter((i) => navItemVisibleTo(i, role))
.map((i) => i.to)
test('a row with no roles is visible to every staff role', () => {
// Self-service: staff are a superset of players, so a moderator reaching their
// own characters is not a privilege, it is the thing every account has.
for (const role of ['admin', 'editor', 'moderator']) {
assert.equal(navItemVisibleTo({ to: '/admin/account' }, role), true)
}
})
test('a role not named on the row cannot see it', () => {
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'moderator'), false)
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'admin'), true)
// An unknown or absent role sees only the ungated rows.
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, undefined), false)
assert.equal(navItemVisibleTo({ to: '/admin/account' }, undefined), true)
})
test('what a moderator sees is exactly the moderation section, plus self-service', () => {
// The two additions the derivation makes over the old MOD_PATHS list are
// Dashboard — whose roles have always named moderator, so the two lists
// disagreed — and My Characters. Both are already permitted server-side.
assert.deepEqual(visibleTo('moderator'), [
'/admin',
'/admin/moderation',
'/admin/moderation/appeals',
'/admin/shard-ops',
'/admin/houses',
'/admin/characters',
'/admin/account',
])
})
test('an admin still sees everything and an editor still sees nothing extra', () => {
assert.equal(visibleTo('admin').length, NAV.flatMap((g) => g.items).length)
assert.deepEqual(visibleTo('editor'), ['/admin', '/admin/characters', '/admin/account'])
})
test('a row with `end` matches exactly — the dashboard is not a prefix', () => {
// The bug this shape exists to prevent: treating `/admin` as a prefix would
// make every path in the admin area allowed for anyone who can see Dashboard.
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin', allowed), true)
assert.equal(isAllowedPath('/admin/users', allowed), false)
assert.equal(isAllowedPath('/admin/users/12', allowed), false)
})
test('every other row covers its own sub-routes', () => {
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin/moderation/appeals/12', allowed), true)
assert.equal(isAllowedPath('/admin/characters/0x4001', allowed), true)
})
test('a sibling path that merely shares a prefix is NOT covered', () => {
const allowed = allowedPathsFor(NAV, 'moderator')
// `/admin/houses-secret` starts with `/admin/houses` as a string; the match is
// on path segments, so it does not start with `/admin/houses/`.
assert.equal(isAllowedPath('/admin/houses-secret', allowed), false)
assert.equal(isAllowedPath('/admin/houses/42', allowed), true)
})
test('Houses is reachable, which is the defect the derivation fixed', () => {
// The redirect used to allow only /admin/moderation*, /admin/shard-ops* and
// /admin/account, while the sidebar showed Houses — so a moderator clicking a
// row in their own nav was bounced back to Moderation.
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin/houses', allowed), true)
})
test('a module row a moderator may see is reachable without core listing it', () => {
// The reason this is derived at all: core cannot hardcode a path it has never
// heard of, and a module row arrives with `roles` like any other.
const withModule = [
...NAV,
{ title: 'Shard', items: [{ to: '/admin/uo/shard-ops', label: 'Ops', roles: ['admin', 'moderator'], moduleId: 'uo' }] },
]
const allowed = allowedPathsFor(withModule, 'moderator')
assert.equal(isAllowedPath('/admin/uo/shard-ops', allowed), true)
assert.equal(isAllowedPath('/admin/uo/shard-ops/queue', allowed), true)
})
test('a nav that is not there does not throw', () => {
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
assert.equal(isAllowedPath('/admin', undefined), false)
})

View File

@@ -0,0 +1,85 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildFeatureGate, OPEN_GATE } from '../src/modules/featureGate.js'
// The feature seam's decision logic (MODULE_SYSTEM.md §1.5, MODULE_API.md §3.3).
// Every branch here fails OPEN, and that is the property under test as much as
// the happy path: this is presentation, the server is the gate, and 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.
const flags = (...names) => new Set(names)
test('a row with no feature is always visible', () => {
const gate = buildFeatureGate(new Map([['uo', flags()]]))
assert.equal(gate({ to: '/site/news' }), true)
})
test('a core row resolves against the owner id `core`', () => {
// Core's ten shard-gated rows carry no moduleId, and core registers its
// provider under `core` (main.jsx) precisely so they resolve without one.
const gate = buildFeatureGate(new Map([['core', flags('atlas')]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
assert.equal(gate({ to: '/site/market', feature: 'market' }), false)
})
test('a module row resolves against ITS module, not another one', () => {
const gate = buildFeatureGate(
new Map([
['uo', flags('atlas')],
['rust', flags('market')],
]),
)
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
// `market` is a flag the OTHER module grants. Resolution is by registration,
// so there is no string a module can write to borrow it.
assert.equal(gate({ to: '/uo/market', feature: 'market', moduleId: 'uo' }), false)
assert.equal(gate({ to: '/rust/market', feature: 'market', moduleId: 'rust' }), true)
})
test('no provider for the owner shows the row', () => {
// The no-module-installed case, and the reason the filter is a correct no-op
// on a bare core rather than a nav that renders nothing.
const gate = buildFeatureGate(new Map())
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
})
test('a provider still loading shows the row', () => {
// useShardFlags returns null until its fetch lands. Blanking the nav on every
// page load and filling it in a moment later is the behaviour this avoids.
const gate = buildFeatureGate(new Map([['core', null]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
})
test('a provider that returned something unusable shows the row', () => {
for (const bad of [undefined, 42, 'atlas', {}, []]) {
const gate = buildFeatureGate(new Map([['core', bad]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true, `failed closed on ${JSON.stringify(bad)}`)
}
})
test('an array-backed provider is not silently treated as a Set', () => {
// `[].has` does not exist, so this is the unusable case above rather than a
// membership test that quietly always fails. Asserted so that a future
// "helpful" normalisation knows it changed a documented behaviour.
const gate = buildFeatureGate(new Map([['core', ['atlas']]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
})
test('a missing map, or a junk row, shows rather than throws', () => {
assert.equal(buildFeatureGate(null)({ feature: 'atlas' }), true)
assert.equal(buildFeatureGate(new Map())(null), true)
assert.equal(buildFeatureGate(new Map())(undefined), true)
})
test('any Set-like satisfies a provider — core does not require a Set', () => {
const gate = buildFeatureGate(new Map([['uo', { has: (name) => name === 'ruleset' }]]))
assert.equal(gate({ feature: 'ruleset', moduleId: 'uo' }), true)
assert.equal(gate({ feature: 'champs', moduleId: 'uo' }), false)
})
test('the open gate is what a component outside the provider gets', () => {
assert.equal(OPEN_GATE({ feature: 'anything' }), true)
})

View File

@@ -0,0 +1,187 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { withModuleNav } from '../src/modules/nav.js'
import { registerNav, _reset } from '../src/modules/registry.js'
import { applyNavOverrides, buildPublicNav } from '../src/lib/navOverrides.js'
// The interleave of module nav rows into core's nav (MODULE_API.md §3.3, Phase 2
// PR 8). Tested against the real merge next door rather than in isolation,
// because the property that matters is a relationship between the two: a module
// row has to be indistinguishable from a core row to everything downstream, and
// the way to prove that is to run the downstream thing on it.
const PUBLIC = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'About', to: '/site/about' },
]
const ADMIN = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'moderator'] }] },
{ title: 'Moderation', items: [{ to: '/admin/moderation', label: 'Moderation' }] },
{ title: 'System', items: [{ to: '/admin/users', label: 'Users' }, { to: '/admin/settings', label: 'Settings' }] },
{ items: [{ to: '/admin/account', label: 'Account' }] },
]
beforeEach(() => _reset())
test('with no module installed the base array is returned unchanged', () => {
// Identity, not a copy: this is what makes the useMemo in each layout honest,
// and what guarantees an instance with no modules renders what it renders now.
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
assert.equal(withModuleNav(ADMIN, 'admin'), ADMIN)
})
test('a flat nav places a module row by the order it asked for', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 1 }] })
assert.deepEqual(
withModuleNav(PUBLIC, 'public').map((i) => i.label),
['Home', 'Atlas', 'News', 'About'],
)
})
test('a flat row with no order appends rather than jumping to the front', () => {
// The 0-default trap: `order ?? 0` would put an unordered row first, which is
// the one place a module could take over the nav without asking for anything.
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
assert.deepEqual(
withModuleNav(PUBLIC, 'public').map((i) => i.label),
['Home', 'News', 'About', 'Atlas'],
)
})
test('an explicit order beats a core row that merely sits at that index', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 2 }] })
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
assert.deepEqual(labels, ['Home', 'News', 'Atlas', 'About'])
})
test('an admin row lands INSIDE the core group it names', () => {
registerNav('uo', {
area: 'admin',
items: [
{ label: 'In-Game Ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30 },
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 0 },
],
})
const nav = withModuleNav(ADMIN, 'admin')
assert.deepEqual(nav.map((g) => g.title), [undefined, 'Moderation', 'System', undefined])
assert.deepEqual(nav[1].items.map((i) => i.label), ['Moderation', 'In-Game Ops'])
// order 0 puts it above both core rows, which is the whole point of the field.
assert.deepEqual(nav[2].items.map((i) => i.label), ['Shard', 'Users', 'Settings'])
})
test('an unknown group appends a new group instead of dropping the row', () => {
// A typo must cost a position, never a link.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton' }] })
const nav = withModuleNav(ADMIN, 'admin')
assert.equal(nav.length, ADMIN.length + 1)
assert.deepEqual(nav.at(-1), { title: 'Moderaton', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton', moduleId: 'uo' }] })
})
test('an admin row with no group gets a trailing untitled group of its own', () => {
// NOT folded into one of core's untitled groups: those are Dashboard at the
// top and Account at the bottom, and a module page belongs beside neither.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas' }] })
const nav = withModuleNav(ADMIN, 'admin')
assert.equal(nav.length, ADMIN.length + 1)
assert.equal(nav.at(-1).title, undefined)
assert.deepEqual(nav.at(-1).items.map((i) => i.label), ['Atlas'])
assert.deepEqual(nav[0].items.map((i) => i.label), ['Dashboard'])
assert.deepEqual(nav[3].items.map((i) => i.label), ['Account'])
})
test('a row whose `to` collides with a core row is dropped, not rendered twice', () => {
// `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 moves both.
const warnings = []
const warn = console.warn
console.warn = (msg) => warnings.push(msg)
try {
registerNav('uo', {
area: 'public',
items: [{ label: 'Not News', to: '/site/news' }, { label: 'Atlas', to: '/uo/atlas' }],
})
const nav = withModuleNav(PUBLIC, 'public')
assert.deepEqual(nav.map((i) => i.label), ['Home', 'News', 'About', 'Atlas'])
assert.equal(warnings.length, 1)
assert.match(warnings[0], /\/site\/news.*collides/)
} finally {
console.warn = warn
}
})
test('two modules cannot claim the same path either', () => {
const warn = console.warn
console.warn = () => {}
try {
registerNav('aa', { area: 'public', items: [{ label: 'First', to: '/shared' }] })
registerNav('zz', { area: 'public', items: [{ label: 'Second', to: '/shared' }] })
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
assert.deepEqual(labels, ['Home', 'News', 'About', 'First'])
} finally {
console.warn = warn
}
})
test('a module row carries its moduleId through, which is how the gate finds it', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }] })
const row = withModuleNav(PUBLIC, 'public').at(-1)
assert.equal(row.moduleId, 'uo')
assert.equal(row.feature, 'atlas')
})
test('areas do not leak into one another', () => {
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
})
// ── The relationship that is the actual requirement ───────────────────────
test('an admin override applies to a module row exactly as to a core row', () => {
// The reason the interleave happens BEFORE the merge and not after: the merge
// drops any key its base array does not declare, so appending module rows
// afterwards would make every one of them unorderable, unrelabellable and
// unhideable — a visible regression the day the UO rows leave core.
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
const base = withModuleNav(PUBLIC, 'public')
const merged = applyNavOverrides(base, {
'/uo/atlas': { label: 'Bestiary', order: 0 },
'/site/news': { order: 3 },
})
assert.deepEqual(merged.map((i) => i.label), ['Bestiary', 'Home', 'About', 'News'])
})
test('an override can hide a module row, and the public tree can section it', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }, { label: 'Market', to: '/uo/market' }] })
const base = withModuleNav(PUBLIC, 'public')
const hidden = buildPublicNav(base, { '/uo/atlas': { hidden: true } })
assert.equal(hidden.some((n) => n.to === '/uo/atlas'), false)
const sectioned = buildPublicNav(base, {
items: { '/uo/market': { section: 'sec_shard' } },
sections: [{ id: 'sec_shard', label: 'Shard', order: 0 }],
})
assert.equal(sectioned[0].kind, 'section')
assert.deepEqual(sectioned[0].items.map((i) => i.to), ['/uo/market'])
})
test('a module row can be moved between admin groups by an override', () => {
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
const base = withModuleNav(ADMIN, 'admin')
const merged = applyNavOverrides(base, { '/admin/uo/link': { group: 'Moderation' } })
assert.deepEqual(merged[1].items.map((i) => i.to), ['/admin/moderation', '/admin/uo/link'])
assert.deepEqual(merged[2].items.map((i) => i.to), ['/admin/users', '/admin/settings'])
})
test('a group a module created is itself a legal override destination', () => {
// Falls out of building the destination set from the base nav it is handed —
// recorded because it is the kind of thing that would otherwise be discovered
// by an admin finding a section they cannot move anything into.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Shard' }] })
const base = withModuleNav(ADMIN, 'admin')
const merged = applyNavOverrides(base, { '/admin/users': { group: 'Shard' } })
assert.deepEqual(merged.at(-1).items.map((i) => i.to), ['/admin/uo/atlas', '/admin/users'])
})

View File

@@ -12,6 +12,7 @@ import {
routesFor, routesFor,
navFor, navFor,
featureProviderFor, featureProviderFor,
featureProviders,
registeredIds, registeredIds,
_reset, _reset,
} from '../src/modules/registry.js' } from '../src/modules/registry.js'
@@ -126,6 +127,28 @@ test('a feature provider is stored under its namespace, with its owner', () => {
assert.equal(featureProviderFor('nothing'), undefined) assert.equal(featureProviderFor('nothing'), undefined)
}) })
test('providers can be enumerated in registration order, with their owner', () => {
// Core's feature context has to CALL each of these, as a hook, in a fixed
// order — so it needs the list, and it needs the owner id to resolve a nav
// row whose `moduleId` says who it belongs to (modules/features.jsx).
const uo = () => null
const rust = () => null
registerFeatureProvider('uo', 'shard', uo)
registerFeatureProvider('rust', 'server', rust)
assert.deepEqual(featureProviders(), [
{ id: 'uo', namespace: 'shard', hook: uo },
{ id: 'rust', namespace: 'server', hook: rust },
])
})
test('enumerating providers is NOT part of the module-facing surface', () => {
// A module asks for a namespace it knows the name of; enumerating what
// everyone else registered is core's business, so `featureProviders` is a
// module export and not a member of window.__rg.registry.
assert.equal(registry.featureProviders, undefined)
assert.equal(typeof featureProviders, 'function')
})
test('every registration marks the module registered', () => { test('every registration marks the module registered', () => {
registerRoutes('a', { public: [{ path: 'x' }] }) registerRoutes('a', { public: [{ path: 'x' }] })
registerNav('b', { area: 'public', items: [] }) registerNav('b', { area: 'public', items: [] })