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>
188 lines
7.2 KiB
JavaScript
188 lines
7.2 KiB
JavaScript
import { test, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import {
|
|
registry,
|
|
registerRoutes,
|
|
registerNav,
|
|
registerFeatureProvider,
|
|
routesFor,
|
|
navFor,
|
|
featureProviderFor,
|
|
featureProviders,
|
|
registeredIds,
|
|
_reset,
|
|
} from '../src/modules/registry.js'
|
|
import { MODULE_API_VERSION } from '../src/modules/version.js'
|
|
|
|
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
|
|
// isolation from React, like the nav-override merge next door, because the
|
|
// property worth proving has nothing to do with rendering: a module gets exactly
|
|
// the URL namespace core gave it, however it spells the paths it registers.
|
|
//
|
|
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
|
|
// there is no DOM in this runner. What it publishes is React, the router and
|
|
// core components: a wiring test would assert that an import statement imported
|
|
// something. Phase 1's spike proved the half that can actually fail, which is a
|
|
// real chunk resolving its externals against the global in a browser under an
|
|
// enforced CSP.
|
|
|
|
beforeEach(() => _reset())
|
|
|
|
test('a module route is namespaced under the module id', () => {
|
|
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
|
|
assert.deepEqual(
|
|
routesFor('public').map((r) => r.path),
|
|
['uo/atlas'],
|
|
)
|
|
})
|
|
|
|
test('a module cannot spell its way out of its namespace', () => {
|
|
// Whatever the module writes, the segment it lands under is core's to choose:
|
|
// leading slashes, several of them, a trailing one, or nothing at all.
|
|
registerRoutes('uo', {
|
|
public: [
|
|
{ path: '/atlas' },
|
|
{ path: '//atlas/creatures' },
|
|
{ path: 'atlas/' },
|
|
{ path: '' },
|
|
],
|
|
})
|
|
assert.deepEqual(
|
|
routesFor('public').map((r) => r.path),
|
|
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
|
|
)
|
|
})
|
|
|
|
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
|
|
// `..` is not stripped, and does not need to be: React Router matches path
|
|
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
|
|
// than a route that resolves somewhere else. Asserted so that a future
|
|
// "cleanup" that starts resolving these knows it changed a behaviour.
|
|
registerRoutes('uo', { public: [{ path: '../admin' }] })
|
|
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
|
|
})
|
|
|
|
test('routes keep their gate and carry the owning module id', () => {
|
|
registerRoutes('uo', {
|
|
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
|
|
})
|
|
const [route] = routesFor('admin')
|
|
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
|
|
assert.equal(route.moduleId, 'uo')
|
|
assert.equal(route.element, 'OPS')
|
|
})
|
|
|
|
test('the three areas are kept apart', () => {
|
|
registerRoutes('uo', {
|
|
public: [{ path: 'atlas' }],
|
|
admin: [{ path: 'link' }],
|
|
player: [{ path: 'chars' }],
|
|
})
|
|
assert.equal(routesFor('public').length, 1)
|
|
assert.equal(routesFor('admin').length, 1)
|
|
assert.equal(routesFor('player').length, 1)
|
|
// An area nobody registered is an empty list, never undefined: App.jsx maps
|
|
// over all three unconditionally.
|
|
_reset()
|
|
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
|
|
})
|
|
|
|
test('an unknown area throws rather than being dropped', () => {
|
|
// Loudly, because the alternative is a module whose pages simply never appear
|
|
// and no indication anywhere of why.
|
|
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
|
|
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
|
|
assert.equal(registeredIds().length, 0)
|
|
})
|
|
|
|
test('nav items sort by order, and equal orders keep load order', () => {
|
|
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
|
|
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
|
|
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
|
|
assert.deepEqual(
|
|
navFor('admin').map((i) => i.label),
|
|
['First', 'Second', 'Third'],
|
|
)
|
|
})
|
|
|
|
test('a nav item with no order sorts after the ones that asked for a place', () => {
|
|
registerNav('uo', {
|
|
area: 'public',
|
|
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
|
|
})
|
|
assert.deepEqual(
|
|
navFor('public').map((i) => i.label),
|
|
['Early', 'Unordered'],
|
|
)
|
|
})
|
|
|
|
test('a feature provider is stored under its namespace, with its owner', () => {
|
|
const hook = () => ({ atlas: true })
|
|
registerFeatureProvider('uo', 'shard', hook)
|
|
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
|
|
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', () => {
|
|
registerRoutes('a', { public: [{ path: 'x' }] })
|
|
registerNav('b', { area: 'public', items: [] })
|
|
registerFeatureProvider('c', 'ns', () => {})
|
|
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
|
|
})
|
|
|
|
test('the registry object handed to modules exposes the whole surface', () => {
|
|
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
|
// member missing from the object is a member that does not exist.
|
|
assert.deepEqual(Object.keys(registry).sort(), [
|
|
'featureProviderFor',
|
|
'navFor',
|
|
'registerFeatureProvider',
|
|
'registerNav',
|
|
'registerRoutes',
|
|
'registeredIds',
|
|
'routesFor',
|
|
])
|
|
})
|
|
|
|
test('the client and server halves declare the same MODULE_API_VERSION', () => {
|
|
// The value is duplicated because it has to be on window.__rg before the first
|
|
// module chunk evaluates, which is earlier than a fetch could answer. This is
|
|
// the test that pays for the copy: a bump that edits one file fails here
|
|
// instead of shipping a core whose two halves disagree about the contract they
|
|
// implement.
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
const server = fs.readFileSync(
|
|
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
|
|
'utf8',
|
|
)
|
|
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
|
|
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
|
|
assert.equal(MODULE_API_VERSION, match[1])
|
|
})
|