7 Commits

Author SHA1 Message Date
bf470c7658 spike(modules): carry /public/atlas/* behind the proposed module surface
THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.

The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:

  • zero internal-file imports from the module into core; the built chunk has
    zero bare import specifiers and bundles no React
  • routes.manifest.json AND routes.guards.json are byte-identical
  • /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
    zero CSP violation reports

729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.

Two things the spike changed in the contract:

  • ctx.express / ctx.validator. A module lives outside server/, so Node never
    reaches server/node_modules and require('express') fails outright — the
    server-side twin of the one-React rule, which §2.6 had only for the client.
  • window.__rg.jsxRuntime, so a module can build with the automatic JSX
    runtime its tooling already assumes rather than being forced to classic.

And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 05:29:35 -05:00
f1dda8fe66 Merge pull request 'ci: run PR checks on pull requests into edge as well as main' (#127) from ci/pr-checks-on-edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 9s
Build container images / build (push) Successful in 56s
SonarQube / analysis (push) Successful in 4m9s
Build container images / deploy (push) Failing after 11m31s
Reviewed-on: #127
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-10 07:42:15 +00:00
4691fd6633 ci: run PR checks on pull requests into edge as well as main
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 1m37s
PR Checks / client-build (pull_request) Successful in 9m13s
Long workstreams land phase by phase on `edge` and reach `main` as a single
cutover. With `branches: [main]` alone, every one of those phase PRs merges
with no checks at all -- no server tests, no client build, no bot install --
and the whole workstream runs blind until the cutover, where the breakage
arrives all at once and un-bisected.

This is not hypothetical: it is what happened to all nine Android M12 phase
PRs in the Android-app repo, whose pr-checks.yml carries the same trigger.

It matters for the module system specifically because Phase 2's exit
criterion IS a CI result -- a zero-line routes.manifest.json diff and a
passing suite -- and that manifest is the frozen URL surface protecting
three shipped clients. A branch accumulating work for weeks needs the gate
more than main does, not less.

Landing before the module-system edge branch is cut, so the first phase PR
is checked. build-images.yml is deliberately untouched: it triggers on push
to main, so images publish and production rolls at the cutover and never
before.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 02:31:43 -05:00
265042eaa5 Merge pull request 'feat(theming): admin-configurable theme, brand assets and navigation (edge → main)' (#126) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 1m27s
Build container images / deploy (push) Successful in 43s
SonarQube / analysis (push) Successful in 4m13s
Reviewed-on: #126
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 06:19:34 +00:00
18815f4c7a Merge pull request 'feat(theming): dropdown sections and added links in the public header (phase 10)' (#125) from feat/theming-nav-phase-10 into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / client-build (pull_request) Successful in 38s
PR Checks / server-tests (pull_request) Successful in 10m17s
Reviewed-on: #125
2026-08-08 06:07:15 +00:00
15cefe5ea1 fix(theme): state .pill's line-height so a button pill matches a link pill
The public header's dropdown trigger is a <button class="pill"> sitting in a row
of <a class="pill"> links, and it rendered ~7px shorter.

It was not failing to pick up the theme: font-size, font-family, padding, border
and box-sizing all matched exactly. The one property that differed was
line-height, because form controls do not inherit it — the UA stylesheet gives
<button> `line-height: normal` (~1.15), while the anchors inherited body's 1.6.
38.02px against 31px, which is precisely 22.016 - 15.8.

Stating it on .pill fixes it at the source rather than patching the one button:
every other property in that rule is already explicit for the same reason, and
this was the remaining gap. The value matches body's 1.6, so no link pill
changes. The ~70 <button class="pill"> elsewhere in the admin gain the same 7px
and now line up with the .btn buttons they sit beside.

.btn has the same latent difference and is deliberately left alone: it is used on
80 buttons and 2 anchors, they never appear on the same row, so nothing is
visibly wrong and the blast radius is not worth it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:56:50 -05:00
b517d7b2df feat(theming): dropdown sections and added links in the public header
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.

This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.

The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.

nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.

The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.

The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.

pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.

Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:42:19 -05:00
65 changed files with 5994 additions and 654 deletions

View File

@@ -1,8 +1,15 @@
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
# build or failing test can't reach the deployable branch. Complements
# Gate every pull request into `main` or `edge` on a fast, DB-free check suite so
# a broken build or failing test can't reach either integration branch. Complements
# build-images.yml, which runs only AFTER merge (on push to main) to publish
# images — this one runs BEFORE merge.
#
# `edge` is listed as well as `main` because long workstreams land phase by phase
# on `edge` and reach `main` as a single cutover (the module system, protocol v3).
# With `branches: [main]` alone, every one of those phase PRs merges with NO checks
# at all and the entire workstream runs blind until the cutover — which is exactly
# what happened to the nine Android M12 phase PRs in that repo. A branch that
# accumulates work for weeks needs the gate more than `main` does, not less.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
@@ -10,6 +17,10 @@
# Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown.
# The workflow now RUNS on PRs into `edge` too, but running is not enforcing:
# blocking a red phase PR needs its own protection rule for `edge`, with the
# same `PR Checks / *` pattern. Without one the checks report and merging stays
# possible anyway.
#
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
# only Node (no Docker socket), and the server tests stub their models + point the
@@ -19,7 +30,7 @@ name: PR Checks
on:
pull_request:
branches: [main]
branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:

View File

@@ -5,6 +5,7 @@ import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js'
// Public
import Portal from './routes/public/Portal.jsx'
@@ -23,8 +24,6 @@ import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
import Atlas from './routes/public/Atlas.jsx'
import AtlasCreature from './routes/public/AtlasCreature.jsx'
import Leaderboards from './routes/public/Leaderboards.jsx'
import Market from './routes/public/Market.jsx'
import MarketVendor from './routes/public/MarketVendor.jsx'
@@ -108,13 +107,19 @@ export default function App() {
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/site/rules" element={<Rules />} />
<Route path="/site/atlas" element={<Atlas />} />
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
<Route path="/site/leaderboards" element={<Leaderboards />} />
<Route path="/site/market" element={<Market />} />
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` (§2.8).
Declared BEFORE the /:slug CMS catch-all: React Router ranks
static segments over dynamic ones so the order is not what saves
us, but keeping them adjacent makes the relationship visible. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
@@ -205,6 +210,17 @@ export default function App() {
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles } that core applies as the
same RoleGate its own routes use (MODULE_API.md §3.3). */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>

View File

@@ -42,6 +42,16 @@ function safeParse(text) {
}
}
// The request PRIMITIVE, exported for installed modules (window.__rg.api — see
// docs/website/MODULE_API.md §3.5). A module owns the paths it calls, because it
// owns the routes at the other end; core owns only the fetch semantics —
// same-origin /api/v1, cookies included, JSON in/out, ApiError on non-2xx.
//
// `api` below stays core's own binding surface. Its `atlas` and `shard`
// namespaces are module bindings that only still live here because Phase 3 has
// not moved them yet.
export { req as request }
export const api = {
// ----- auth -----
me: () => req('/auth/me'),

View File

@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
// One dropdown section in the public header — a menu an admin created from
// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
//
// It **opens on click, never on hover**. Hover menus are unusable on touch, and
// the alternative (make the trigger a link too) means tapping to open navigates
// away instead. A section is a container, not a destination, so the trigger has
// no `to` at all.
//
// Everything else here is the keyboard and dismissal contract a menu needs:
// Escape closes and returns focus to the trigger, an outside press closes,
// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` +
// `aria-expanded` are what let a screen reader announce it as a menu rather than
// as a button that mysteriously changes the page.
export default function NavDropdown({ label, items, linkStyle }) {
const [open, setOpen] = useState(false)
const wrapRef = useRef(null)
const triggerRef = useRef(null)
const location = useLocation()
// The trigger shows the active treatment when the page you are on lives in
// this menu — otherwise entering a section makes the header look like nothing
// is selected.
const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
// Close on navigation. The menu is rendered inside a sticky header that
// survives route changes, so nothing else would dismiss it.
useEffect(() => setOpen(false), [location.pathname])
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
// `mousedown`, not `click`: closing on the press means a press that lands on
// another trigger opens that one in the same gesture.
const onOutside = (e) => {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onOutside)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onOutside)
}
}, [open])
// Roving focus with the arrow keys, wrapping at both ends.
const onMenuKeyDown = (e) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
e.preventDefault()
const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])]
if (links.length === 0) return
const at = links.indexOf(document.activeElement)
const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length
links[at === -1 ? 0 : next].focus()
}
return (
<div ref={wrapRef} style={{ position: 'relative' }} onKeyDown={onMenuKeyDown}>
<button
ref={triggerRef}
type="button"
className="pill"
aria-haspopup="true"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
...(holdsActive || open
? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' }
: {}),
}}
>
{label}
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{open && (
<div
role="menu"
aria-label={label}
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
minWidth: 190,
// The header wraps, so a menu near the right edge must not push the
// page sideways on a narrow screen.
maxWidth: 'calc(100vw - 24px)',
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: 6,
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line)',
background: 'var(--panel-flat)',
boxShadow: 'var(--shadow-card)',
zIndex: 40,
}}
>
{items.map((item) => (
<NavLink
key={item.kind === 'link' ? item.id : item.to}
to={item.to}
end={item.end}
role="menuitem"
data-menu-item=""
onClick={() => setOpen(false)}
className="sans"
style={({ isActive }) => ({
padding: '7px 10px',
borderRadius: 'var(--radius-input)',
fontSize: '0.85rem',
textDecoration: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
...linkStyle({ isActive }),
...(isActive ? {} : { color: 'var(--muted)' }),
})}
>
{item.label}
</NavLink>
))}
</div>
)}
</div>
)
}

View File

@@ -5,7 +5,9 @@ import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
import { applyNavOverrides } from '../lib/navOverrides.js'
import NavDropdown from './NavDropdown.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { navFor } from '../modules/registry.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
// One consistent top nav for the whole public site. Every page gets the same
@@ -32,7 +34,6 @@ export const NAV = [
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
{ label: 'Market', to: '/site/market', feature: 'market' },
{ label: 'About', to: '/site/about' },
@@ -50,17 +51,36 @@ export default function SiteHeader() {
const shardFeatures = useShardFeatures()
// An admin may relabel, reorder and hide these entries from Admin →
// Navigation (THEMING_AND_NAV.md §7). Two things about the order here:
// Navigation, and may group them into dropdown sections alongside links of
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
//
// • the override merge runs FIRST and the feature filter after it, so the
// filter stays the boundary — an override cannot un-hide a shard surface
// this viewer may not see, whatever it says;
// • with no stored row, applyNavOverrides returns NAV itself, so an
// this viewer may not see, whatever it says. `pruneNav` applies the same
// check inside a section and drops one it leaves empty, so a dropdown
// never opens onto nothing;
// • with no stored row this is the coded NAV, in code order, so an
// untouched instance renders exactly what it renders today.
// Installed modules' entries interleave into this list by `order` BEFORE the
// override merge, so an admin edits one nav rather than "core's, plus whatever
// the module appended" — and a module item is hideable and re-labelable
// exactly like a core one. `order` defaults high, which lands module entries
// where the UO items already sat: after the content links, before About.
const base = useMemo(() => {
const items = navFor('public')
if (items.length === 0) return NAV
const merged = [...NAV]
for (const item of items) {
const at = Number.isFinite(item.order) ? item.order : merged.length
merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature })
}
return merged
}, [])
const nav = useMemo(() => {
const merged = applyNavOverrides(NAV, parseJsonSetting(settings.nav_public))
return merged.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
}, [settings.nav_public, shardFeatures])
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
}, [base, settings.nav_public, shardFeatures])
// Where the auth entry points: staff → admin, player → portal, else sign in.
let account
@@ -93,11 +113,15 @@ export default function SiteHeader() {
{siteTitle}
</Link>
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{nav.map((l) => (
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
{l.label}
</NavLink>
))}
{nav.map((l) =>
l.kind === 'section' ? (
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
) : (
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
{l.label}
</NavLink>
),
)}
{!loading && (
<NavLink
to={account.to}

View File

@@ -263,4 +263,240 @@ export function buildNavOverrides(groups, baseNav, stored = null) {
return out
}
// ── The public header: dropdown sections and added links ────────────────
//
// Phase 10. The public nav is the one nav an admin can restructure rather than
// only reorder: they may create dropdown **sections**, drop coded entries into
// them, and add **links** of their own to pages on this site.
//
// The invariant §7 rests on survives, and it survives structurally rather than
// by vigilance: coded entries stay keyed by a `to` the base array must declare,
// so an override still cannot invent a route or touch a `roles`/`feature` gate,
// while everything that CAN name an arbitrary path lives in `links` where the
// path rule is applied. An added link carries no gate of its own and needs none
// — the page behind it enforces its own access, so a link to somewhere the
// viewer cannot reach 403s exactly as typing the URL would.
//
// Stored shape (server/src/utils/navOverrides.js is the writer):
// { items: {"<to>": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] }
// A bare map is still read as the items map — unambiguous, because every item
// key is a path and so can never be the string `items`.
function unwrapPublic(overrides) {
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
return { items: {}, sections: [], links: [] }
}
const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items)
const items = wrapped ? overrides.items : overrides
const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : []
const links = wrapped && Array.isArray(overrides.links) ? overrides.links : []
return { items, sections, links }
}
// Forgiving, like every other read here: an entry that is not usable is dropped
// and its neighbours kept.
function readSections(sections) {
const out = []
const seen = new Set()
for (const s of sections) {
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue
if (typeof s.label !== 'string' || !s.label.trim()) continue
seen.add(s.id)
out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined })
}
return out
}
function readLinks(links, knownSections) {
const out = []
const seen = new Set()
for (const l of links) {
if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue
if (typeof l.label !== 'string' || !l.label.trim()) continue
// Same rule the server writes by. A stored value that would leave the origin
// is dropped rather than rendered, so a hand-edited row cannot put an
// off-site link in the header.
if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue
seen.add(l.id)
out.push({
id: l.id,
label: l.label.trim(),
to: l.to,
order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined,
section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null,
})
}
return out
}
/**
* The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`.
*
* @param {Array} baseNav the hardcoded public NAV — still the only source of
* `to`, `feature` and `end` for a coded entry
* @param {object|null} overrides the parsed nav_public row
* @param {{keepHidden?: boolean}} [opts] the editor keeps hidden entries so
* they can be un-hidden, and gets `defaultLabel` for the reset affordance;
* the header must not render them at all
* @returns {Array}
*/
export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) {
if (!Array.isArray(baseNav)) return []
const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides)
const sections = readSections(rawSections)
const knownSections = new Set(sections.map((s) => s.id))
const links = readLinks(rawLinks, knownSections)
// Coded entries, keyed by a `to` the base array declares. Anything else in the
// map is dropped here, exactly as in applyNavOverrides.
const known = new Set(baseNav.map((i) => i.to))
const entries = new Map()
for (const [to, raw] of Object.entries(items)) {
if (!known.has(to)) continue
const entry = cleanEntry(raw, new Set())
if (!entry) continue
if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section
entries.set(to, entry)
}
const nodes = []
baseNav.forEach((item, index) => {
const o = entries.get(item.to)
if (o?.hidden && !keepHidden) return
nodes.push({
kind: 'item',
...item,
...(o?.label ? { label: o.label } : {}),
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
section: o?.section ?? null,
__order: o?.order,
__index: index,
})
})
// An admin-created entity with no stored order appends after the coded ones,
// in creation order, rather than jumping to the front on a 0 default.
let next = baseNav.length
for (const section of sections) {
nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ })
}
for (const link of links) {
nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ })
}
const place = (list) =>
list
.map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
.map(({ n }) => {
const { __order, __index, section, ...rest } = n
return rest
})
const top = place(nodes.filter((n) => n.kind === 'section' || !n.section))
return top.map((node) =>
node.kind === 'section'
? { ...node, items: place(nodes.filter((n) => n.section === node.id)) }
: node,
)
}
/**
* Apply the caller's visibility gate — and drop a section it leaves empty.
*
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
* with real correctness risk: a section whose every entry is hidden by shard
* visibility must not render as a menu that opens onto nothing. The predicate
* stays the caller's, so this module still knows nothing about shard features.
*
* Added links carry no gate, so they are always visible — see the note above.
*
* @param {Array} tree from buildPublicNav
* @param {(item: object) => boolean} isVisible applied to coded items only
* @returns {Array}
*/
export function pruneNav(tree, isVisible) {
if (!Array.isArray(tree)) return []
const keep = (node) => node.kind !== 'item' || isVisible(node)
return tree
.map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node))
.filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node)))
}
/**
* The editor's tree back as a nav_public value to store.
*
* Returns the **bare items map** when there are no sections and no added links,
* so a nav that does not use this feature stores exactly what phases 6-8 stored.
*
* @param {Array} tree the editor's current tree
* @param {Array} baseNav the hardcoded public NAV
* @param {object|null} stored as loaded, so an entry for a feature-gated item
* this admin could not see survives their save
* @returns {object} `{}` when nothing differs from the code default
*/
export function buildPublicNavOverrides(tree, baseNav, stored = null) {
if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {}
const baseLabels = new Map(baseNav.map((i) => [i.to, i.label]))
const sections = []
const links = []
const items = {}
// Flatten to (node, containerId, indexInContainer), which is all the writer
// needs: a section's own position is its index in the top-level list.
const placed = []
tree.forEach((node, index) => {
placed.push({ node, section: null, index })
if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i }))
})
// Orders are written whenever this nav has any structure of its own: a section
// exists only because the admin put it somewhere, so its position is never
// "whatever the code says". Without sections the rule is phase 6-8's — write
// orders only if the sequence actually moved.
const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link')
const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to))
const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to)
const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to)
const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i])
const writeOrder = hasStructure || moved
for (const { node, section, index } of placed) {
if (node.kind === 'section') {
sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) })
continue
}
if (node.kind === 'link') {
links.push({
id: node.id,
label: (node.label || '').trim() || node.to,
to: node.to,
...(section ? { section } : {}),
...(writeOrder ? { order: index } : {}),
})
continue
}
const entry = {}
const label = typeof node.label === 'string' ? node.label.trim() : ''
if (label && label !== baseLabels.get(node.to)) entry.label = label
if (node.hidden === true) entry.hidden = true
if (section) entry.section = section
if (writeOrder) entry.order = index
if (Object.keys(entry).length > 0) items[node.to] = entry
}
// Carry through an entry for a coded item this admin's palette never showed
// them (shard-feature gated), so their save does not silently reset it.
const { items: storedItems } = unwrapPublic(stored)
for (const [to, entry] of Object.entries(storedItems)) {
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
}
if (sections.length === 0 && links.length === 0) return items
const out = { items }
if (sections.length) out.sections = sections
if (links.length) out.links = links
return out
}
export default applyNavOverrides

View File

@@ -2,12 +2,37 @@ import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import './styles/theme.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
// Installed modules are `<script type="module" src="/modules/<id>/entry.js">`
// tags the server injects into <head> (server/src/utils/htmlShell.js); module
// scripts are deferred, so they run after this bundle and resolve their
// externals against the global this call sets up.
publishSharedDependencies()
// Render after DOMContentLoaded rather than immediately.
//
// Deferred scripts execute in document order and all of them finish before
// DOMContentLoaded fires. Waiting for that event is therefore the guarantee that
// every installed module has finished registering its routes and nav before
// React reads the registry — no loading state, no re-render, no ordering race
// between core's bundle and a module's. If this bundle happens to evaluate after
// the event has already fired (a cached, fast path), readyState is checked and
// render runs at once.
function mount() {
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true })
} else {
mount()
}

View File

@@ -0,0 +1,100 @@
// ── The client-side module registry ────────────────────────────────────────
//
// A module's prebuilt chunk registers its routes, nav entries and feature
// provider here, and App.jsx / the nav components read them back. This is the
// client half of docs/website/MODULE_API.md §3.3.
//
// Timing is the whole design. Module chunks are `<script type="module" src>`
// tags injected into <head> by the server (utils/htmlShell.js). Module scripts
// are deferred, so they evaluate after the SPA's own bundle has run — which is
// where window.__rg is published — and before DOMContentLoaded. main.jsx waits
// for that same event before calling render(), so registration is complete
// before React reads any of this and there is no re-render to orchestrate.
//
// Registration is therefore a plain synchronous write with no subscribers, not
// an observable store. If that ever changes, it changes here and not in twelve
// consumers.
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const featureProviders = new Map()
const registered = new Set()
const AREAS = ['public', 'admin', 'player']
function assertArea(area, call) {
if (!AREAS.includes(area)) throw new Error(`${call}: unknown area "${area}"`)
}
/**
* Route components for one area.
* @param {string} id the module id, used to namespace the URL segment
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`; `path` is relative to the module's
* namespace and core prefixes it (`/uo/…`, `/admin/uo/…`, `/player/uo/…`)
*/
export function registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
assertArea(area, 'registerRoutes')
for (const route of list) {
// Prefixed here rather than by the module, so a module cannot claim a path
// outside its own namespace however it spells `path`.
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...route, path, moduleId: id })
}
}
registered.add(id)
}
/**
* Nav entries, interleaved into CORE groups rather than appended as a block —
* today's UO items sit inside core's Moderation and System groups, and a "UO"
* group at the bottom would be a visible regression (MODULE_SYSTEM.md §1.4).
* @param {string} id
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
*/
export function registerNav(id, spec) {
const { area, items } = spec || {}
assertArea(area, 'registerNav')
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
}
/**
* The hook that answers "which of this module's features may this viewer see".
* Core keeps a generic flag context and owns none of the semantics; with no
* module installed the nav filter is a correct no-op, because no core nav item
* carries a `feature` today (MODULE_SYSTEM.md §1.5).
*/
export function registerFeatureProvider(id, namespace, hook) {
featureProviders.set(namespace, { id, hook })
}
export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for, stable within equal orders so two
// modules registering the same slot stay in load (alphabetical id) order.
export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
export const registeredIds = () => [...registered]
// Test seam.
export function _reset() {
for (const area of AREAS) {
routes[area].length = 0
nav[area].length = 0
}
featureProviders.clear()
registered.clear()
}
export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
routesFor,
navFor,
featureProviderFor,
registeredIds,
}

View File

@@ -0,0 +1,65 @@
// ── window.__rg — the shared-dependency global ─────────────────────────────
//
// A module's client half is a PREBUILT ESM chunk (the operator never builds
// anything), served same-origin, and loaded under `script-src 'self'` with no
// 'unsafe-inline'. That combination is what rules out an import map: an import
// map has to be an inline <script type="importmap">, and CSP forbids it
// (MODULE_SYSTEM.md §1.14). So the shared dependencies ride on a global and the
// module's externals resolve against it — docs/website/MODULE_API.md §3.2.
//
// There is exactly ONE React in the page and core owns it. A module that bundled
// its own would get a second hook dispatcher and fail at the first useState.
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
// The automatic JSX runtime. Without this a module would have to build with
// `jsxRuntime: 'classic'` — its bundler emits `react/jsx-runtime` imports by
// default, and those have to resolve to CORE's React like every other one.
// Exposing it here is what lets a module use the modern default.
import * as jsxRuntime from 'react/jsx-runtime'
import { registry } from './registry.js'
import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError } from '../api/client.js'
// The kit is CURATED AND CLOSED, not a re-export of components/ — see §3.4.
// Adding to it is a minor MODULE_API_VERSION bump; changing a member's props is
// a major one. That is a real constraint on core, and it is the price of module
// pages looking like the site they are installed in.
const ui = {
PublicLayout,
PageHeader,
Loading,
ErrorState,
EmptyState,
useAsync,
useAuth,
useSite,
}
// The request PRIMITIVE, not the api object: api.atlas and api.shard are module
// bindings that live in core's client today and move out with the module (§3.5).
// A module owns the paths it calls, which is right — it owns the routes at the
// other end.
const api = { request, ApiError }
export function publishSharedDependencies() {
window.__rg = Object.freeze({
version: MODULE_API_VERSION,
react,
reactDom,
router,
jsxRuntime,
registry,
ui: Object.freeze(ui),
api: Object.freeze(api),
})
}

View File

@@ -0,0 +1,8 @@
// The client's copy of MODULE_API_VERSION. Must equal the server's
// (server/src/modules/version.js) — they version ONE contract, and a module
// checks whichever half it is talking to.
//
// Duplicated rather than fetched: the value has to be on window.__rg before the
// first module script evaluates, and that is earlier than any network round trip.
// A test asserts the two files agree.
export const MODULE_API_VERSION = '1.0.0'

View File

@@ -14,7 +14,8 @@ import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
import { buildNavRows, buildNavOverrides } from '../../../lib/navOverrides.js'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js'
import { refreshNavOverrides } from '../../../lib/useNavOverrides.js'
import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx'
@@ -95,9 +96,23 @@ function EyeIcon({ off }) {
)
}
function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.to })
const renamed = row.label !== row.defaultLabel
/**
* One editable nav row, shared by all three tabs.
*
* The destination control is generic because the two navs that have one mean
* different things by it: the admin sidebar moves rows between the four coded
* sections, the public header between admin-created dropdowns. Both are "pick a
* container", so both get one `<select>` rather than cross-container dragging —
* which is a lot of interaction surface for something an admin does once.
*
* @param {Array<{value: string, label: string}>} [destinations] omit for a nav
* with no containers (the player portal)
* @param {() => void} [onDelete] only an admin-authored link can be deleted;
* a coded row is hidden, never removed
*/
export function Row({ row, id, destinations, destination, onDestination, onChange, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
const renamed = row.defaultLabel !== undefined && row.label !== row.defaultLabel
const locked = row.to === SELF
return (
@@ -121,10 +136,10 @@ function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup
<input
className="input"
value={row.label}
placeholder={row.defaultLabel}
placeholder={row.defaultLabel || row.to}
maxLength={64}
onChange={(e) => onChange({ ...row, label: e.target.value })}
aria-label={`Label for ${row.defaultLabel}`}
aria-label={`Label for ${row.defaultLabel || row.to}`}
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem' }}
/>
{/* The route, for orientation — it is what the override is keyed by. Fixed
@@ -157,28 +172,44 @@ function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup
reset
</button>
)}
{groupTitles.length > 0 && (
{destinations && destinations.length > 0 && (
<select
className="select"
value={currentGroup ?? ''}
onChange={(e) => onMoveGroup(row.to, e.target.value || null)}
aria-label={`Section for ${row.defaultLabel}`}
value={destination ?? ''}
onChange={(e) => onDestination(e.target.value || null)}
aria-label={`Section for ${row.defaultLabel || row.to}`}
style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
>
{/* "(no section)" is offered only to a row that is coded into one of
the untitled groups (Dashboard, Account) — for anything else it is
a move that cannot be stored: an override names an existing titled
section or nothing at all (§6.4), so picking it would silently do
nothing. Such a row can still move OUT and back, which clears the
override rather than storing a null group. */}
{baseGroup === null && <option value="">(no section)</option>}
{groupTitles.map((t) => (
<option key={t} value={t}>
{t}
{destinations.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
)}
{onDelete && (
<button
type="button"
className="sans"
title="Remove this link"
onClick={onDelete}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: 'var(--muted)',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '0.76rem',
}}
>
×
</button>
)}
{/* A coded row is hidden, never removed — the route still exists. An
admin-authored link is the opposite: there is nothing to fall back to,
so it is deleted instead (the × above). */}
{!onDelete && (
<button
type="button"
className="sans"
@@ -205,6 +236,7 @@ function Row({ row, groupTitles, currentGroup, baseGroup, onChange, onMoveGroup
>
<EyeIcon off={row.hidden} />
</button>
)}
</li>
)
}
@@ -226,6 +258,13 @@ export default function NavEditor() {
// 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;
// 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
// rather than reset, and only an entry for a route the code no longer declares
// at all should be dropped.
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
const palettes = useMemo(
() => ({
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
@@ -246,7 +285,12 @@ export default function NavEditor() {
const next = {}
for (const { key } of TABS) {
const stored = parseJsonSetting(all[key])
next[key] = { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
// The public header is a tree (sections are entries in the top-level
// order); the other two are the fixed-frame grouped/flat shape.
next[key] =
key === 'nav_public'
? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) }
: { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
}
setState(next)
})
@@ -269,14 +313,23 @@ export default function NavEditor() {
if (error && !state) return <ErrorState message={error} />
const current = state[tab]
const groupTitles = current.groups.map((g) => g.title).filter(Boolean)
const isPublic = tab === 'nav_public'
const groupTitles = isPublic ? [] : current.groups.map((g) => g.title).filter(Boolean)
// Where each row is declared in code, so the section dropdown can offer only
// the destinations an override is able to express.
const baseGroups = new Map(
(Array.isArray(palettes[tab]) && palettes[tab][0]?.items
(!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
: []),
)
// The admin sidebar can only move a row between the four coded sections, and
// "(no section)" only for a row coded into an untitled one — for anything else
// it is a move an override cannot express (§6.4), so offering it would
// silently do nothing.
const groupDestinations = (baseGroup) => [
...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []),
...groupTitles.map((t) => ({ value: t, label: t })),
]
function mutate(updater) {
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
@@ -284,6 +337,12 @@ export default function NavEditor() {
setSaved('')
}
function setTree(tree) {
setState((s) => ({ ...s, [tab]: { ...s[tab], tree } }))
setDirty((d) => ({ ...d, [tab]: true }))
setSaved('')
}
const onRowChange = (next) =>
mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) })))
@@ -327,8 +386,17 @@ export default function NavEditor() {
setBusy(true)
setError('')
try {
const overrides = buildNavOverrides(current.groups, palettes[tab], current.stored)
const empty = Object.keys(overrides).length === 0
const overrides = isPublic
? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored)
: buildNavOverrides(current.groups, fullNavs[tab], current.stored)
// A wrapper with an empty `items` and no sections/links says nothing
// either, so "empty" is about the whole value, not just its key count.
const empty =
Object.keys(overrides).length === 0 ||
(overrides.items !== undefined &&
Object.keys(overrides.items).length === 0 &&
!overrides.sections?.length &&
!overrides.links?.length)
// Nothing differs from the code default, so there is nothing to store —
// and a row that says nothing would still read as "this nav was
// customised". Delete it instead (§2, §4.1).
@@ -353,7 +421,12 @@ export default function NavEditor() {
setError('')
try {
await api.admin.resetSetting(tab)
setState((s) => ({ ...s, [tab]: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) } }))
setState((s) => ({
...s,
[tab]: isPublic
? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) }
: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) },
}))
setDirty((d) => ({ ...d, [tab]: false }))
setSaved('')
await propagate(tab)
@@ -410,6 +483,12 @@ export default function NavEditor() {
</span>
{/* ── Rows ───────────────────────────────────────────────── */}
{/* The public header gets its own editor: a section there is an entry in
the top-level order that an admin created, not a fixed frame the code
declares, so it is a tree rather than a list of groups. */}
{isPublic ? (
<PublicNavTree tree={current.tree} onChange={setTree} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{current.groups.map((group, groupIndex) => (
<div key={group.title ?? `group-${groupIndex}`}>
@@ -420,12 +499,12 @@ export default function NavEditor() {
{group.items.map((row) => (
<Row
key={row.to}
id={row.to}
row={row}
groupTitles={groupTitles}
currentGroup={group.title ?? null}
baseGroup={baseGroups.get(row.to) ?? null}
destinations={groupTitles.length > 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null}
destination={group.title ?? ''}
onDestination={(value) => onMoveGroup(row.to, value)}
onChange={onRowChange}
onMoveGroup={onMoveGroup}
/>
))}
{group.items.length === 0 && (
@@ -439,6 +518,7 @@ export default function NavEditor() {
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">

View File

@@ -0,0 +1,310 @@
import { useState } from 'react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import Modal from '../../../components/Modal.jsx'
import { Row } from './NavEditor.jsx'
// The Public tab of Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
//
// The public header is the one nav an admin can restructure rather than only
// reorder, so it needs its own editor: a **section is itself an entry in the
// top-level order**, which the fixed coded sections of the admin sidebar never
// are. That is the whole reason this is not the grouped editor with a different
// label — there, groups are a fixed frame and only membership moves.
//
// The tree is `[{kind: 'item' | 'link' | 'section', ...}]`, one level deep, and
// comes from the same `buildPublicNav` the header renders, so what an admin
// drags is what visitors get.
const uid = (prefix) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`
// A path on this site, matching what the server will accept. Checked here so the
// admin gets the message while the field is in front of them; the server's 400
// stays the backstop, not the first feedback.
export function badLinkPath(value) {
const v = (value || '').trim()
if (!v) return 'Enter a path.'
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('//')) {
return 'Links must point somewhere on this site — start with “/”.'
}
if (!v.startsWith('/')) return 'Start the path with “/”, for example /wiki/new-player-guide.'
if (/[\s<>"'\\]/.test(v)) return 'A path cannot contain spaces or quotes.'
if (v.length > 128) return 'That path is too long.'
return null
}
function SectionCard({ section, index, children, onChange, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
return (
<li
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
listStyle: 'none',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-card)',
background: isDragging ? 'var(--blue)' : 'transparent',
padding: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="sans"
aria-label={`Reorder ${section.label}`}
{...attributes}
{...listeners}
style={{ border: 'none', background: 'transparent', color: 'var(--dim)', cursor: 'grab', padding: '2px 4px', touchAction: 'none' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<circle cx="9" cy="6" r="1.6" /><circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" /><circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" /><circle cx="15" cy="18" r="1.6" />
</svg>
</button>
<input
className="input"
value={section.label}
maxLength={64}
placeholder="Section name"
onChange={(e) => onChange({ ...section, label: e.target.value })}
aria-label={`Name for section ${index + 1}`}
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem', fontWeight: 600 }}
/>
<span className="sans dim" style={{ fontSize: '0.7rem' }}>dropdown</span>
<button
type="button"
className="sans"
title="Delete this section — the entries inside move back out, they are not removed"
onClick={onDelete}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: 'var(--muted)',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '0.76rem',
}}
>
×
</button>
</div>
{children}
</li>
)
}
export default function PublicNavTree({ tree, onChange }) {
const [adding, setAdding] = useState(null) // {label, to, error} while the modal is open
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const sections = tree.filter((n) => n.kind === 'section')
const destinations = [{ value: '', label: 'Top level' }, ...sections.map((s) => ({ value: s.id, label: s.label || 'Section' }))]
const keyOf = (node) => (node.kind === 'item' ? node.to : node.id)
// Every mutation rebuilds the tree; there is no partial in-place editing, which
// keeps "what will be saved" exactly "what is on screen".
const replace = (nextTree) => onChange(nextTree)
const updateNode = (key, next) =>
replace(
tree.map((node) => {
if (keyOf(node) === key) return next
if (node.kind !== 'section') return node
return { ...node, items: node.items.map((child) => (keyOf(child) === key ? next : child)) }
}),
)
// Moving between containers is the dropdown, not a drag. The entry lands at the
// end of its destination, where it is visible and can then be dragged home.
const moveTo = (key, sectionId) => {
let moving = null
const stripped = tree
.map((node) => {
if (node.kind === 'section') {
const items = node.items.filter((child) => {
if (keyOf(child) !== key) return true
moving = child
return false
})
return { ...node, items }
}
if (keyOf(node) === key) {
moving = node
return null
}
return node
})
.filter(Boolean)
if (!moving) return
if (!sectionId) return replace([...stripped, moving])
return replace(
stripped.map((node) => (node.kind === 'section' && node.id === sectionId ? { ...node, items: [...node.items, moving] } : node)),
)
}
const addSection = () => replace([...tree, { kind: 'section', id: uid('sec'), label: 'New section', items: [] }])
// Deleting a section must NOT delete what is inside it: those are coded pages
// and the admin's own links, and losing them to a mis-click would be the one
// destructive act this screen could commit. They move back to the top level.
const deleteSection = (id) => {
const section = tree.find((n) => n.kind === 'section' && n.id === id)
if (!section) return
replace([...tree.filter((n) => keyOf(n) !== id), ...(section.items || [])])
}
const deleteLink = (id) =>
replace(
tree
.filter((n) => keyOf(n) !== id)
.map((n) => (n.kind === 'section' ? { ...n, items: n.items.filter((c) => keyOf(c) !== id) } : n)),
)
const submitLink = () => {
const error = badLinkPath(adding.to)
if (error) return setAdding({ ...adding, error })
const label = adding.label.trim()
if (!label) return setAdding({ ...adding, error: 'Give the link a name.' })
replace([...tree, { kind: 'link', id: uid('lnk'), label, to: adding.to.trim() }])
return setAdding(null)
}
const onDragEnd = (containerId) => (event) => {
const { active, over } = event
if (!over || active.id === over.id) return
if (containerId === null) {
const from = tree.findIndex((n) => keyOf(n) === active.id)
const to = tree.findIndex((n) => keyOf(n) === over.id)
if (from < 0 || to < 0) return
return replace(arrayMove(tree, from, to))
}
return replace(
tree.map((node) => {
if (node.kind !== 'section' || node.id !== containerId) return node
const from = node.items.findIndex((c) => keyOf(c) === active.id)
const to = node.items.findIndex((c) => keyOf(c) === over.id)
if (from < 0 || to < 0) return node
return { ...node, items: arrayMove(node.items, from, to) }
}),
)
}
const renderRow = (node, sectionId) => (
<Row
key={keyOf(node)}
id={keyOf(node)}
row={node}
destinations={destinations}
destination={sectionId ?? ''}
onDestination={(value) => moveTo(keyOf(node), value)}
onChange={(next) => updateNode(keyOf(node), next)}
onDelete={node.kind === 'link' ? () => deleteLink(node.id) : undefined}
/>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(null)}>
<SortableContext items={tree.map(keyOf)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: 0, padding: 0 }}>
{tree.map((node, index) =>
node.kind === 'section' ? (
<SectionCard
key={node.id}
section={node}
index={index}
onChange={(next) => updateNode(node.id, next)}
onDelete={() => deleteSection(node.id)}
>
{/* A nested context, so a drag inside a dropdown reorders that
dropdown rather than escaping into the header. */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(node.id)}>
<SortableContext items={(node.items || []).map(keyOf)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '10px 0 0', padding: '0 0 0 22px' }}>
{(node.items || []).map((child) => renderRow(child, node.id))}
{(node.items || []).length === 0 && (
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '4px 2px' }}>
Empty — an empty dropdown is not shown on the site.
</li>
)}
</ul>
</SortableContext>
</DndContext>
</SectionCard>
) : (
renderRow(node, null)
),
)}
</ul>
</SortableContext>
</DndContext>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button type="button" className="pill" onClick={addSection}>
+ Add dropdown section
</button>
<button type="button" className="pill" onClick={() => setAdding({ label: '', to: '', error: null })}>
+ Add link
</button>
</div>
{adding && (
<Modal
title="Add a link"
onClose={() => setAdding(null)}
width={480}
footer={
<>
<button className="pill" onClick={() => setAdding(null)}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submitLink}>Add link</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label style={{ display: 'block' }}>
<span className="field-label">Name</span>
<input
className="input"
value={adding.label}
maxLength={64}
placeholder="Player Guide"
onChange={(e) => setAdding({ ...adding, label: e.target.value, error: null })}
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Path on this site</span>
<input
className="input"
value={adding.to}
maxLength={128}
placeholder="/wiki/new-player-guide"
onChange={(e) => setAdding({ ...adding, to: e.target.value, error: null })}
/>
</label>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
Links point somewhere on this site — a wiki page, a custom page, any section of the site.
They are not gated: the page itself still decides who may open it, so a link to something
restricted behaves exactly as typing its address would.
</p>
{adding.error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{adding.error}</span>}
</div>
</Modal>
)}
</div>
)
}

View File

@@ -190,6 +190,14 @@ a.card:focus-visible {
background: rgba(11, 22, 48, 0.5);
font-family: var(--sans);
font-size: 0.86rem;
/* Stated, not inherited. A <button class="pill"> would otherwise take the UA
stylesheet's `line-height: normal` — form controls do not inherit it from
body — and come out ~7px shorter than an <a class="pill"> beside it. Every
other property here is already explicit for the same reason; this was the
one gap, and it only became visible once the public header put a button
pill (a dropdown trigger) on the same row as the link pills. Matches
body's 1.6, so no link pill changes. */
line-height: 1.6;
text-decoration: none;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;

View File

@@ -1,7 +1,14 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyNavOverrides, buildNavRows, buildNavOverrides } from '../src/lib/navOverrides.js'
import {
applyNavOverrides,
buildNavRows,
buildNavOverrides,
buildPublicNav,
pruneNav,
buildPublicNavOverrides,
} from '../src/lib/navOverrides.js'
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
// of this feature with real correctness risk, so it is tested in isolation from
@@ -348,3 +355,190 @@ test('degenerate input yields an empty result rather than throwing', () => {
assert.deepEqual(buildNavOverrides(null, FLAT), {})
assert.deepEqual(buildNavOverrides([], null), {})
})
// ── The public header: sections and added links (phase 10) ────────────────
//
// The one nav an admin can restructure rather than only reorder. The invariant
// that has to survive is §7's, in its narrower form: a CODED entry still cannot
// have its `to` or `feature` touched, and everything that can name an arbitrary
// path lives in `links`, where the path rule applies.
const PUB = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'About', to: '/site/about' },
]
const shape = (tree) =>
tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))
test('no override yields the coded header, in code order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('a phase 6-8 bare map still reads as the items map', () => {
// Nothing has shipped, but a row written during review must not become
// unreadable just because the wrapper arrived.
assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
'Home',
'Announcements',
'Champions',
'Guilds',
'About',
])
})
const SECTIONED = {
items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
}
test('a section collects its members and sits in the top-level order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('an added link is kept apart from the coded items', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
assert.equal(link.to, '/wiki/new-player-guide')
assert.equal(link.id, 'lnk_bbbb')
// It carries no gate of its own — that is the documented contract, and the
// page behind it is what actually enforces access.
assert.equal(link.feature, undefined)
assert.equal(link.roles, undefined)
})
test('an off-origin link is dropped rather than rendered', () => {
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
assert.equal(
tree.some((n) => n.kind === 'link'),
false,
`${to} should be dropped`,
)
}
})
test('an item naming a section that does not exist stays at the top level', () => {
const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('an override still cannot introduce a coded route', () => {
const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
assert.equal(
tree.some((n) => n.to === '/site/secret'),
false,
)
})
test('hidden entries are dropped for the site and kept for the editor', () => {
const overrides = { items: { '/site/news': { hidden: true } } }
assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
})
// ── pruneNav: the empty dropdown ──────────────────────────────────────────
test('a section keeps the entries the viewer may see', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const out = pruneNav(tree, (i) => i.feature !== 'guilds')
assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
})
test('a section whose every entry is gated out does not render at all', () => {
// The case that matters: a dropdown that opens onto nothing is worse than no
// dropdown, and shard visibility can empty one at any time.
const overrides = {
items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
sections: [{ id: 'sec_aaaa', label: 'The World' }],
}
const tree = buildPublicNav(PUB, overrides)
assert.deepEqual(shape(pruneNav(tree, () => true)), [
'Home',
'News',
'About',
{ 'The World': ['Champions', 'Guilds'] },
])
assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
})
test('an added link is never pruned — it carries no gate', () => {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
assert.equal(
pruneNav(tree, () => false).some((n) => n.kind === 'link'),
true,
)
})
// ── The editor round trip ─────────────────────────────────────────────────
test('an untouched public editor saves nothing', () => {
assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
})
test('a nav with no sections still stores the plain items map', () => {
// Adding this feature changed nothing for a nav that does not use it.
const tree = buildPublicNav(PUB, null, { keepHidden: true })
tree[1].label = 'Announcements'
const out = buildPublicNavOverrides(tree, PUB)
assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
assert.equal(out.items, undefined)
})
test('the sectioned round trip is stable and renders what the editor showed', () => {
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const first = buildPublicNavOverrides(tree, PUB)
const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
assert.deepEqual(second, first)
assert.deepEqual(shape(buildPublicNav(PUB, first)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('deleting a section returns its entries to the top level, never deletes them', () => {
// The one destructive act this screen could commit, so it is locked here.
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const section = tree.find((n) => n.kind === 'section')
const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
const out = buildPublicNavOverrides(flattened, PUB)
const rendered = buildPublicNav(PUB, out)
assert.equal(
rendered.some((n) => n.kind === 'section'),
false,
)
assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
})
test('an override for a feature-gated item outside the palette survives a save', () => {
// §8.1 filters the editor to what this admin can see. The rows come from their
// palette, but membership is judged against the FULL coded nav — otherwise a
// row a shard feature hid from them is indistinguishable from a deleted route,
// and their save would silently reset it.
const palette = PUB.filter((i) => i.feature !== 'champs')
// The editor was opened on a nav that only hides champs — which their palette
// does not show them. `stored` additionally carries a label for a row they CAN
// see, and which they have since reset.
const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
const out = buildPublicNavOverrides(tree, PUB, stored)
assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
})
test('a stored entry for a route the code no longer declares is dropped on save', () => {
const tree = buildPublicNav(PUB, null, { keepHidden: true })
assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
})

50
modules/uo/SPIKE.md Normal file
View File

@@ -0,0 +1,50 @@
# module-uo — the Phase 1 spike
**This branch is evidence, not implementation.** `spike/module-atlas` is cut from `edge` and is
never merged. Phase 2 rebuilds the loader properly, with the `installed_modules` table, the full
state machine and the admin panel behind it; Phase 3 does the real extraction.
What it demonstrates, and the results, are written up in
[`docs/website/MODULE_API.md`](../../../docs/website/MODULE_API.md) Part 7. In one line: the six
public spawn-atlas routes now live in a module, at byte-identical URLs, with the client half loading
as a prebuilt ESM chunk under `script-src 'self'`.
## Reproducing it
```bash
# 1. build the module's client chunk (its CI would do this and ship the result)
cd modules/uo/client && npm install && npm run build # → dist/entry.js
# 2. build core's client
cd ../../../client && npm install && npm run build
# 3. run the server against the local MariaDB
cd ../server && npm start
```
Then:
- `/uo/atlas` and `/uo/atlas/lizardman` render from the module's chunk.
- `GET /api/v1/public/atlas/*` answers exactly as before — `npm run routes:manifest -- --check`
reports the surface unchanged.
- `npm test` in `server/` (core, 729) and `node --test` in `modules/uo/server/` (module, 81).
`dist/entry.js` is committed here **only** because this branch is the evidence for a design
decision and a reviewer should be able to inspect the built artifact without a toolchain. A real
module publishes it from CI into its release bundle and never commits it.
## What in here is not design
Three things are consequences of stopping at six routes, spelled out in MODULE_API.md §7.5:
1. **Core reaches into this module twice** — `server/src/router/v1/admin/shardAtlas.controller.js`
and `server/test/atlasController.test.js`. The five admin atlas routes sit inside the `/shard`
admin prefix core still owns, so they cannot move until the whole prefix does.
2. **`server/utils/visibility.js` is a copy** of core's `utils/shardVisibility.js`, which core still
needs for the shard routes not yet extracted. Two caches over one table, briefly.
3. **There is no `swagger-fragment.json`** — it needs core's merge helper on the other side, which
is Phase 2.
Also note the table names here are `shard_*`, not `uo_*`. That is deliberate and grandfathered by an
allowlist in the loader: renaming twenty-seven live tables is a data migration this workstream does
not do. Every module written after this one carries its id as a table prefix.

409
modules/uo/client/dist/entry.js vendored Normal file
View File

@@ -0,0 +1,409 @@
const B = window.__rg.jsxRuntime, { jsx: t, jsxs: l, Fragment: A } = B, U = window.__rg.react, {
useState: h,
useEffect: I,
useMemo: j,
useCallback: W,
useRef: ne,
useContext: se,
useReducer: re,
createElement: ie,
cloneElement: le,
createContext: oe,
forwardRef: ce,
memo: de,
Fragment: me,
Children: pe,
isValidElement: ue,
StrictMode: he,
Suspense: ge,
lazy: fe
} = U, E = window.__rg.router, {
Link: z,
NavLink: ye,
Navigate: xe,
Outlet: we,
Route: be,
Routes: ve,
useParams: M,
useNavigate: Se,
useLocation: Ne,
useSearchParams: $e,
createBrowserRouter: Ce,
RouterProvider: ke
} = E, { PublicLayout: F, PageHeader: D, Loading: C, ErrorState: v, EmptyState: S, useAsync: k, useAuth: Re, useSite: ze } = window.__rg.ui, { request: f } = window.__rg.api, w = (e) => e ? `?${e}` : "", O = {
creatures: (e = {}) => {
const a = new URLSearchParams();
return e.q && a.set("q", e.q), e.facet && a.set("facet", e.facet), e.limit && a.set("limit", e.limit), e.offset && a.set("offset", e.offset), f(`/public/atlas/creatures${w(a.toString())}`);
},
creature: (e, a = {}) => {
const s = new URLSearchParams();
return a.facet && s.set("facet", a.facet), a.points && s.set("points", a.points), f(`/public/atlas/creatures/${encodeURIComponent(e)}${w(s.toString())}`);
},
regions: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/regions${w(a.toString())}`);
},
landmarks: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/landmarks${w(a.toString())}`);
},
champions: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), f(`/public/atlas/champions${w(a.toString())}`);
},
meta: () => f("/public/atlas/meta")
}, y = { atlas: O }, H = 50, x = (e) => Number.isFinite(e) ? e.toLocaleString() : "—", Q = [
{ key: "creatures", label: "Creatures" },
{ key: "champions", label: "Champion altars" },
{ key: "places", label: "Places" }
];
function R({ active: e, onClick: a, children: s }) {
return /* @__PURE__ */ t(
"button",
{
type: "button",
onClick: a,
className: "sans",
style: {
fontSize: "0.78rem",
padding: "5px 12px",
borderRadius: 999,
cursor: "pointer",
color: e ? "var(--bg-deep)" : "var(--muted)",
background: e ? "var(--accent)" : "transparent",
border: `1px solid ${e ? "var(--accent)" : "var(--line)"}`
},
children: s
}
);
}
function G({ creature: e }) {
const a = Object.entries(e.facets || {}).sort((s, r) => r[1] - s[1]);
return /* @__PURE__ */ l(
z,
{
to: `/uo/atlas/${encodeURIComponent(e.slug)}`,
className: "panel",
style: {
padding: "13px 15px",
display: "flex",
alignItems: "center",
gap: 14,
textDecoration: "none",
color: "inherit"
},
children: [
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
/* @__PURE__ */ t(
"div",
{
className: "display",
style: {
fontSize: "0.98rem",
color: "var(--head)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
},
children: e.name
}
),
/* @__PURE__ */ t("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: a.length === 0 ? "—" : a.map(([s, r]) => `${s} (${r})`).join(" · ") })
] }),
/* @__PURE__ */ l("div", { className: "sans", style: { flex: "none", textAlign: "right" }, children: [
/* @__PURE__ */ t("div", { style: { color: "var(--head)", fontSize: "0.92rem" }, children: x(e.total) }),
/* @__PURE__ */ l("div", { className: "dim", style: { fontSize: "0.68rem", letterSpacing: "0.05em" }, children: [
x(e.points),
" spawners"
] })
] })
]
}
);
}
function V({ q: e, facet: a }) {
const [s, r] = h({ loading: !0, error: null, items: [], total: 0 }), [i, p] = h(!1), o = W(
async (n) => await y.atlas.creatures({ q: e, facet: a, limit: H, offset: n }),
[e, a]
);
I(() => {
let n = !0;
return r({ loading: !0, error: null, items: [], total: 0 }), o(0).then((d) => {
n && r({ loading: !1, error: null, items: d.creatures || [], total: d.total || 0 });
}).catch((d) => n && r({ loading: !1, error: d, items: [], total: 0 })), () => {
n = !1;
};
}, [o]);
const c = async () => {
p(!0);
try {
const n = await o(s.items.length);
r((d) => ({ ...d, items: [...d.items, ...n.creatures || []], total: n.total ?? d.total }));
} catch {
} finally {
p(!1);
}
};
return s.loading ? /* @__PURE__ */ t(C, {}) : s.error ? /* @__PURE__ */ t(v, { message: "Could not load the bestiary right now." }) : s.items.length === 0 ? /* @__PURE__ */ t(S, { children: "Nothing in the atlas matches that." }) : /* @__PURE__ */ l(A, { children: [
/* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.78rem", margin: "0 0 12px" }, children: [
"Showing ",
x(s.items.length),
" of ",
x(s.total)
] }),
/* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: s.items.map((n) => /* @__PURE__ */ t(G, { creature: n }, n.slug)) }),
s.items.length < s.total && /* @__PURE__ */ t("div", { style: { textAlign: "center", marginTop: 16 }, children: /* @__PURE__ */ t("button", { type: "button", className: "btn", onClick: c, disabled: i, children: i ? "Loading…" : "Load more" }) })
] });
}
function X({ facet: e }) {
const { loading: a, error: s, data: r } = k(() => y.atlas.champions(e), [e]);
return a ? /* @__PURE__ */ t(C, {}) : s ? /* @__PURE__ */ t(v, { message: "Could not load the champion altars right now." }) : !r || r.length === 0 ? /* @__PURE__ */ t(S, { children: "No champion altars are configured." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: r.map((i) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "13px 15px", display: "flex", gap: 14, alignItems: "center" }, children: [
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
/* @__PURE__ */ t("div", { className: "display", style: { fontSize: "0.98rem", color: "var(--head)" }, children: i.label || i.name }),
/* @__PURE__ */ l("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: [
i.facet,
i.group ? ` · ${i.group}` : "",
" · ",
i.x,
", ",
i.y
] })
] }),
/* @__PURE__ */ t("span", { className: "sans", style: { flex: "none", fontSize: "0.76rem", color: "var(--muted)" }, children: i.randomType ? "Random champion" : i.type || "—" })
] }, i.slug)) });
}
function J({ q: e, facet: a }) {
const { loading: s, error: r, data: i } = k(
() => Promise.all([y.atlas.regions({ q: e, facet: a }), y.atlas.landmarks({ q: e, facet: a })]),
[e, a]
), p = j(() => {
if (!i) return [];
const [o, c] = i;
return [
...o.map((n) => ({ key: `r:${n.facet}:${n.name}`, name: n.name, facet: n.facet, detail: n.parent || n.type || "Region", kind: "Region" })),
...c.map((n) => ({ key: `l:${n.facet}:${n.group || ""}:${n.name}:${n.x}:${n.y}`, name: n.group ? `${n.group} — ${n.name}` : n.name, facet: n.facet, detail: `${n.x}, ${n.y}`, kind: "Landmark" }))
].sort((n, d) => n.name.localeCompare(d.name));
}, [i]);
return s ? /* @__PURE__ */ t(C, {}) : r ? /* @__PURE__ */ t(v, { message: "Could not load places right now." }) : p.length === 0 ? /* @__PURE__ */ t(S, { children: "No regions or landmarks match that." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: p.map((o) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "10px 14px", display: "flex", gap: 12, alignItems: "baseline" }, children: [
/* @__PURE__ */ t("span", { className: "sans", style: { flex: 1, minWidth: 0, color: "var(--head)", fontSize: "0.88rem" }, children: o.name }),
/* @__PURE__ */ l("span", { className: "sans dim", style: { fontSize: "0.72rem" }, children: [
o.facet,
" · ",
o.detail
] }),
/* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.66rem", letterSpacing: "0.06em", flex: "none" }, children: o.kind })
] }, o.key)) });
}
function K() {
var L, _, q;
const [e, a] = h("creatures"), [s, r] = h(""), [i, p] = h(""), [o, c] = h(""), n = k(() => y.atlas.meta());
I(() => {
const m = setTimeout(() => p(s.trim()), 250);
return () => clearTimeout(m);
}, [s]);
const d = ((L = n.data) == null ? void 0 : L.facets) || [], u = ((_ = n.data) == null ? void 0 : _.counts) || null, N = (q = n.data) != null && q.importedAt ? new Date(n.data.importedAt) : null;
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
/* @__PURE__ */ t(
D,
{
eyebrow: "Bestiary",
title: "Spawn atlas",
lead: "Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
}
),
u && /* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.76rem", margin: "-12px 0 18px" }, children: [
x(u.creatures),
" creatures across ",
x(u.points),
" spawners",
Number.isFinite(u.unresolvedPoints) && u.points ? ` · ${Math.round((u.points - u.unresolvedPoints) / u.points * 100)}% placed to a named region or landmark` : "",
N ? ` · parsed ${N.toLocaleDateString()}` : ""
] }),
/* @__PURE__ */ t("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: Q.map((m) => /* @__PURE__ */ t(R, { active: e === m.key, onClick: () => a(m.key), children: m.label }, m.key)) }),
e !== "champions" && /* @__PURE__ */ t(
"input",
{
className: "input",
type: "search",
value: s,
onChange: (m) => r(m.target.value),
placeholder: e === "creatures" ? "Search creatures…" : "Search regions and landmarks…",
style: { width: "100%", marginBottom: 12 }
}
),
d.length > 0 && /* @__PURE__ */ l("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }, children: [
/* @__PURE__ */ t(R, { active: o === "", onClick: () => c(""), children: "All facets" }),
d.map((m) => /* @__PURE__ */ t(R, { active: o === m, onClick: () => c(m), children: m }, m))
] }),
n.error && /* @__PURE__ */ t(v, { message: "Could not load the atlas right now." }),
!n.error && !n.loading && !N && /* @__PURE__ */ t(S, { children: "The spawn atlas has not been imported yet." }),
!n.error && N && /* @__PURE__ */ l(A, { children: [
e === "creatures" && /* @__PURE__ */ t(V, { q: i, facet: o }),
e === "champions" && /* @__PURE__ */ t(X, { facet: o }),
e === "places" && /* @__PURE__ */ t(J, { q: i, facet: o })
] })
] }) });
}
const g = (e) => Number.isFinite(e) ? e.toLocaleString() : "—";
function Y(e, a) {
const s = (r) => r >= 60 ? `${Math.round(r / 60)}m` : `${r}s`;
return !Number.isFinite(e) || !Number.isFinite(a) ? null : e === a ? s(e) : `${s(e)}–${s(a)}`;
}
function P({ title: e, right: a, children: s }) {
return /* @__PURE__ */ l("section", { className: "panel", style: { padding: 18 }, children: [
/* @__PURE__ */ l("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [
/* @__PURE__ */ t("h2", { className: "display", style: { margin: "0 0 12px", fontSize: "1.02rem", color: "var(--head)" }, children: e }),
a
] }),
s
] });
}
function Z({ places: e }) {
return e.length === 0 ? /* @__PURE__ */ t("p", { className: "sans dim", style: { margin: 0 }, children: "No placed spawners." }) : /* @__PURE__ */ t("div", { children: e.map((a) => /* @__PURE__ */ l(
"div",
{
className: "sans",
style: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
padding: "6px 0",
borderBottom: "1px solid var(--line)",
fontSize: "0.86rem"
},
children: [
/* @__PURE__ */ t("span", { style: { minWidth: 0, color: "var(--head)" }, children: a.label }),
/* @__PURE__ */ l("span", { className: "dim", style: { flex: "none" }, children: [
a.facet,
" · ",
g(a.spawners),
" spawner",
a.spawners === 1 ? "" : "s",
" · up to",
" ",
g(a.maxAlive),
" at once"
] })
]
},
`${a.facet}:${a.label}`
)) });
}
function ee({ spawners: e, truncated: a }) {
const [s, r] = h(!1);
return e.length === 0 ? null : /* @__PURE__ */ t(
P,
{
title: "Individual spawners",
right: /* @__PURE__ */ t(
"button",
{
type: "button",
className: "sans",
onClick: () => r((i) => !i),
style: { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: "0.78rem" },
children: s ? "Hide" : `Show ${g(e.length)}`
}
),
children: s && /* @__PURE__ */ l("div", { style: { overflowX: "auto" }, children: [
/* @__PURE__ */ l("table", { className: "sans", style: { width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }, children: [
/* @__PURE__ */ t("thead", { children: /* @__PURE__ */ l("tr", { style: { textAlign: "left", color: "var(--muted)" }, children: [
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Place" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Facet" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Coords" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Max" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 0 8px 0" }, children: "Respawn" })
] }) }),
/* @__PURE__ */ t("tbody", { children: e.map((i) => /* @__PURE__ */ l("tr", { style: { borderTop: "1px solid var(--line)" }, children: [
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0", color: "var(--head)" }, children: i.label }),
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: i.facet }),
/* @__PURE__ */ l("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: [
i.x,
", ",
i.y
] }),
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: g(i.maxCount) }),
/* @__PURE__ */ t("td", { style: { padding: "6px 0" }, className: "dim", children: Y(i.minDelay, i.maxDelay) || "—" })
] }, i.id)) })
] }),
a && /* @__PURE__ */ t("p", { className: "sans dim", style: { fontSize: "0.74rem", margin: "10px 0 0" }, children: "Only the largest spawners are listed." })
] })
}
);
}
function te() {
var o;
const { slug: e } = M(), { loading: a, error: s, data: r } = k(() => y.atlas.creature(e), [e]), i = (s == null ? void 0 : s.status) === 404 || (s == null ? void 0 : s.message) === "Not Found", p = j(
() => Object.entries((r == null ? void 0 : r.facets) || {}).sort((c, n) => n[1] - c[1]),
[r]
);
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
/* @__PURE__ */ t("p", { className: "sans", style: { marginBottom: 8 }, children: /* @__PURE__ */ t(z, { to: "/uo/atlas", style: { color: "var(--accent)", fontSize: "0.78rem" }, children: "← Spawn atlas" }) }),
a && /* @__PURE__ */ t(C, {}),
s && !i && /* @__PURE__ */ t(v, { message: "Could not load that creature right now." }),
i && /* @__PURE__ */ t(S, { children: "Nothing by that name spawns on this shard." }),
!a && !s && r && /* @__PURE__ */ l(A, { children: [
/* @__PURE__ */ t(
D,
{
eyebrow: "Bestiary",
title: r.name,
lead: `Up to ${g(r.total)} alive at once across ${g(r.points)} spawner${r.points === 1 ? "" : "s"}.`
}
),
/* @__PURE__ */ l("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
/* @__PURE__ */ t(
P,
{
title: "Where it spawns",
right: /* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.74rem" }, children: p.map(([c, n]) => `${c} (${n})`).join(" · ") }),
children: /* @__PURE__ */ t(Z, { places: r.places || [] })
}
),
/* @__PURE__ */ t(ee, { spawners: r.spawners || [], truncated: !!r.spawnersTruncated }),
((o = r.alsoHere) == null ? void 0 : o.length) > 0 && /* @__PURE__ */ t(P, { title: "Shares a spawner with", children: /* @__PURE__ */ t("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: r.alsoHere.map((c) => /* @__PURE__ */ l(
z,
{
to: `/uo/atlas/${encodeURIComponent(c.slug)}`,
className: "sans",
style: {
fontSize: "0.78rem",
padding: "4px 11px",
borderRadius: 999,
border: "1px solid var(--line)",
color: "var(--muted)",
textDecoration: "none"
},
children: [
c.name,
" ",
/* @__PURE__ */ l("span", { className: "dim", children: [
"×",
g(c.shared)
] })
]
},
c.slug
)) }) })
] })
] })
] }) });
}
const $ = "uo", T = "^1.0.0";
function ae(e) {
const [a] = String(e || "").split(".");
return a === T.replace(/^\^/, "").split(".")[0];
}
const b = window.__rg;
b ? ae(b.version) ? (b.registry.registerRoutes($, {
// Paths are relative to the module's namespace; core prefixes them, so these
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
public: [
{ path: "atlas", element: /* @__PURE__ */ t(K, {}) },
{ path: "atlas/:slug", element: /* @__PURE__ */ t(te, {}) }
]
}), b.registry.registerNav($, {
area: "public",
items: [{ label: "Atlas", to: "/uo/atlas", feature: "atlas", order: 12 }]
})) : console.error(`[module-${$}] needs core API ${T}, this core is ${b.version} — not registering`) : console.error(`[module-${$}] window.__rg is missing — core did not publish its shared dependencies`);

1691
modules/uo/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "module-uo-client",
"private": true,
"version": "0.1.0-spike",
"type": "module",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,47 @@
// module-uo's API bindings.
//
// These used to be `api.atlas` inside core's client/src/api/client.js — a module
// namespace living in core (MODULE_API.md §3.5). The module owns the paths
// because it owns the routes at the other end; core hands over only the request
// primitive: same-origin /api/v1, cookies included, JSON in/out, ApiError on a
// non-2xx.
const { request } = window.__rg.api
const withQs = (s) => (s ? `?${s}` : '')
export const atlas = {
creatures: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.facet) qs.set('facet', opts.facet)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return request(`/public/atlas/creatures${withQs(qs.toString())}`)
},
creature: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.points) qs.set('points', opts.points)
return request(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
},
regions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return request(`/public/atlas/regions${withQs(qs.toString())}`)
},
landmarks: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return request(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
champions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
return request(`/public/atlas/champions${withQs(qs.toString())}`)
},
meta: () => request('/public/atlas/meta'),
}
export const api = { atlas }

View File

@@ -0,0 +1,53 @@
// ── module-uo · client entry point ─────────────────────────────────────────
//
// The prebuilt ESM chunk core loads as
// `<script type="module" src="/modules/uo/entry.js">`. Same-origin, so
// `script-src 'self'` admits it with no nonce and no inline — which is the
// entire reason the client half is shaped this way (MODULE_SYSTEM.md §1.14).
//
// It evaluates AFTER core's bundle (deferred module scripts run in document
// order) and BEFORE core renders (main.jsx waits for DOMContentLoaded), so
// registering synchronously here is enough — there is no loading state to
// coordinate and no re-render to trigger.
import Atlas from './pages/Atlas.jsx'
import AtlasCreature from './pages/AtlasCreature.jsx'
const ID = 'uo'
const CORE_API = '^1.0.0'
// The client-side twin of the server's coreApi check. A module built against a
// contract this core does not implement must refuse to register rather than
// half-work: a missing kit member is a blank page three clicks in, and the
// version is knowable now.
function compatible(version) {
const [major] = String(version || '').split('.')
return major === CORE_API.replace(/^\^/, '').split('.')[0]
}
const rg = window.__rg
if (!rg) {
// Not an exception: throwing from a module script is an uncaught error in the
// page, and a module failing to load must never be the site failing to load.
console.error(`[module-${ID}] window.__rg is missing — core did not publish its shared dependencies`)
} else if (!compatible(rg.version)) {
console.error(`[module-${ID}] needs core API ${CORE_API}, this core is ${rg.version} — not registering`)
} else {
rg.registry.registerRoutes(ID, {
// Paths are relative to the module's namespace; core prefixes them, so these
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
public: [
{ path: 'atlas', element: <Atlas /> },
{ path: 'atlas/:slug', element: <AtlasCreature /> },
],
})
// Interleaves into core's public nav rather than appending a "UO" group.
// `order: 12` puts it where the Atlas link already sat — after Wiki and the
// shard boards, before About. `feature` is resolved by the provider below.
rg.registry.registerNav(ID, {
area: 'public',
items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas', order: 12 }],
})
}

View File

@@ -1,10 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
import { api } from '../api.js'
// ── The spawn atlas ─────────────────────────────────────────────────────────
//
@@ -52,7 +49,7 @@ function CreatureCard({ creature }) {
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
return (
<Link
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
className="panel"
style={{
padding: '13px 15px',

View File

@@ -1,10 +1,7 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
import { api } from '../api.js'
// One creature: where it spawns, and what spawns alongside it.
//
@@ -138,7 +135,7 @@ export default function AtlasCreature() {
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p className="sans" style={{ marginBottom: 8 }}>
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
← Spawn atlas
</Link>
</p>
@@ -175,7 +172,7 @@ export default function AtlasCreature() {
{data.alsoHere.map((other) => (
<Link
key={other.slug}
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
className="sans"
style={{
fontSize: '0.78rem',

View File

@@ -0,0 +1,3 @@
const reactDom = window.__rg.reactDom
export default reactDom
export const { createRoot, hydrateRoot } = reactDom

View File

@@ -0,0 +1,8 @@
// The automatic JSX runtime, from core's global. Every .jsx file in this module
// compiles to imports from here, so this is the single hottest path in the
// bundle — and the one that would silently produce a SECOND React if it resolved
// to a bundled copy instead.
const jsx = window.__rg.jsxRuntime
export default jsx
export const { jsx: jsxFn, jsxs, Fragment } = jsx
export { jsxFn as jsx }

View File

@@ -0,0 +1,10 @@
// react-router-dom from core's global. Same singleton argument as React, with a
// sharper edge: the router's context is created by whichever copy is loaded, so
// a second copy would give module pages an EMPTY router context — <Link> would
// throw and useParams() would return {} rather than the URL's params.
const router = window.__rg.router
export default router
export const {
Link, NavLink, Navigate, Outlet, Route, Routes, useParams, useNavigate,
useLocation, useSearchParams, createBrowserRouter, RouterProvider,
} = router

19
modules/uo/client/src/shim/react.js vendored Normal file
View File

@@ -0,0 +1,19 @@
// React, taken from core's global rather than bundled.
//
// There is exactly ONE React in the page and core owns it (MODULE_API.md §3.2).
// A module that bundled its own would get a second hook dispatcher and fail at
// the first useState — so `react` is declared external in vite.config.js and
// aliased here.
//
// Why an alias module rather than rollup's `output.globals`: `globals` only
// applies to iife/umd output, and this is an ES module. An alias is the ESM
// equivalent, and it also keeps named imports (`import { useState } from
// 'react'`) working unchanged in the page source.
const react = window.__rg.react
export default react
export const {
useState, useEffect, useMemo, useCallback, useRef, useContext, useReducer,
createElement, cloneElement, createContext, forwardRef, memo, Fragment,
Children, isValidElement, StrictMode, Suspense, lazy,
} = react

View File

@@ -0,0 +1,13 @@
// Core's shared UI kit, from the global.
//
// This is the §3.4 kit: a curated, closed set — the layout chrome, the three
// page states, the async hook and the two read-only contexts. It exists because
// a module page that does not use core's layout is a module page that does not
// look like the site it is installed in, and drifts further every time core's
// chrome changes.
//
// Anything NOT in here, the module bundles itself.
const { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite } =
window.__rg.ui
export { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite }

View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Library mode: one prebuilt ESM chunk, published by CI and dropped onto the
// operator's volume. The operator never builds anything (MODULE_SYSTEM.md §2.5).
//
// The four externals are the whole contract with core. Declaring them external
// alone is not enough, though: rollup would emit bare `import 'react'`
// specifiers, which a browser cannot resolve without an import map — and CSP
// forbids the inline <script type="importmap"> that would provide one. So each
// is ALIASED to a two-line shim that re-exports from window.__rg, and the
// external list then only has to stop Vite from following them into node_modules
// this package does not have.
const shim = (f) => path.resolve(import.meta.dirname, 'src/shim', f)
export default defineConfig({
plugins: [react()],
resolve: {
// EXACT matches, via the array form. Vite's object form does PREFIX
// replacement, so a plain `react` key also rewrote `react/jsx-runtime` into
// `src/shim/react.js/jsx-runtime` — a path that does not exist, and the
// first thing this build hit.
alias: [
{ find: /^react$/, replacement: shim('react.js') },
{ find: /^react\/jsx-runtime$/, replacement: shim('react-jsx-runtime.js') },
{ find: /^react-dom\/client$/, replacement: shim('react-dom-client.js') },
{ find: /^react-router-dom$/, replacement: shim('react-router-dom.js') },
],
},
build: {
lib: {
entry: path.resolve(import.meta.dirname, 'src/entry.jsx'),
formats: ['es'],
fileName: () => 'entry.js',
},
outDir: 'dist',
emptyOutDir: true,
// Same reason as core's client: no inline bootstrap script for
// `script-src 'self'` to trip on.
modulePreload: { polyfill: false },
},
})

14
modules/uo/module.json Normal file
View File

@@ -0,0 +1,14 @@
{
"id": "uo",
"name": "Ultima Online",
"version": "0.1.0-spike",
"coreApi": "^1.0.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/atlas"]
},
"capabilities": ["atlas"]
}

88
modules/uo/server/core.js Normal file
View File

@@ -0,0 +1,88 @@
// ── The module's single point of contact with core ─────────────────────────
//
// Every other file in this module imports THIS file instead of reaching into
// the website's tree. That is the whole mechanical trick behind the
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
// changed by one `require` line each, and a CI grep for a relative path
// escaping the module root can then be an exact test rather than a heuristic.
//
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
// that need it are plain CommonJS modules that were written against top-level
// requires. Rather than thread ctx through nine constructors, register() parks
// it here once and everything else reads it lazily.
//
// Lazily is load-bearing: this file is required at module-require time, which is
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
// startup-time database dependency the loader is careful not to have.
let ctx = null
/** Called exactly once, by server/index.js, at the top of register(). */
function init(next) {
if (ctx) throw new Error('module-uo: core.init() called twice')
ctx = next
}
function require_() {
if (!ctx) throw new Error('module-uo: core used before register() ran')
return ctx
}
// Forwarders rather than re-exports: `const { query } = require('./core')`
// destructures at require time, which is before init(), so a plain re-export
// would capture undefined. Each of these resolves ctx at CALL time.
const query = (sql, params) => require_().db.query(sql, params)
const logger = (namespace) => require_().log(namespace)
const settings = {
get: (key) => require_().settings.get(key),
// `updatedBy` is the third parameter core's settings.model.set carries — the
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
// would silently lose the audit attribution rather than fail.
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
getInstanceName: () => require_().settings.getInstanceName(),
}
const auth = {
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
}
const middleware = {
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
validate: (req, res, next) => require_().middleware.validate(req, res, next),
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
requireRole: (...roles) => {
// requireRole is a FACTORY, so it must be resolved at call time and the
// resulting middleware kept — resolving it per request would build a new
// closure on every hit.
let built = null
return (req, res, next) => {
built = built || require_().middleware.requireRole(...roles)
return built(req, res, next)
}
},
}
module.exports = {
init,
// Shared server dependencies, taken from core rather than required directly.
// A module lives outside server/, so `require('express')` from here does not
// resolve at all — and even where it did, a second express in the process
// would be a second Router prototype. Same rule as React on the client.
get express() { return require_().express },
get validator() { return require_().validator },
query,
logger,
settings,
auth,
middleware,
get pool() { return require_().db.pool },
get secretBox() { return require_().secretBox },
get push() { return require_().push },
get uploads() { return require_().uploads },
get posts() { return require_().posts },
get paths() { return require_().paths },
get moduleId() { return require_().moduleId },
}

View File

@@ -0,0 +1,21 @@
-- ── module-uo · purge ──────────────────────────────────────────────────────
--
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
-- retains its data, and an operator who wants the data gone has to say so.
--
-- Required because this module declares a schema fragment: a module that can
-- create tables and cannot drop them leaves an operator with orphaned data and
-- no supported way to remove it.
--
-- Dropped children-first even though these tables carry no foreign keys, so the
-- order stays correct if Phase 3 adds one.
DROP TABLE IF EXISTS shard_atlas_pending;
DROP TABLE IF EXISTS shard_atlas_meta;
DROP TABLE IF EXISTS shard_champion_spawns;
DROP TABLE IF EXISTS shard_landmarks;
DROP TABLE IF EXISTS shard_regions;
DROP TABLE IF EXISTS shard_spawn_point_types;
DROP TABLE IF EXISTS shard_spawn_points;
DROP TABLE IF EXISTS shard_spawn_creatures;

View File

@@ -0,0 +1,166 @@
-- ── module-uo · schema fragment ────────────────────────────────────────────
--
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
-- It inherits core's rules because it goes through core's splitter: idempotent
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
--
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
-- server/db/schema.sql. Phase 3 brings the other nineteen.
--
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
-- loader: renaming twenty-seven live tables is a data migration this workstream
-- deliberately does not do, and the prefix rule holds for every module written
-- after this one.
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- `facets` is a per-facet point count, so the facet filter and "where does this
-- live" both answer without touching shard_spawn_points.
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
total INT NOT NULL DEFAULT 0,
points INT NOT NULL DEFAULT 0,
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
-- creature art: sprites live in the operator's own client .mul/.uop files and
-- are theirs to extract and place under uploads/atlas/. The UI renders without
-- art when this is NULL, which is the normal case.
art VARCHAR(255) NULL,
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
-- and FULLTEXT's min-token-length would break searches for names like "orc".
INDEX idx_shard_spawn_creatures_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per spawner. `region`/`landmark` are the resolved place name — the
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
-- the resolved display string (region, else landmark, else 'Wilderness').
CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
max_count INT NOT NULL DEFAULT 0,
min_delay INT NOT NULL DEFAULT 0,
max_delay INT NOT NULL DEFAULT 0,
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
tod_end INT NOT NULL DEFAULT 0,
tod_mode INT NOT NULL DEFAULT 0,
region VARCHAR(120) NULL,
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
-- types (a single Trammel point spawns six), each with its own max. This is how
-- /atlas/creatures/:slug finds the places a creature appears.
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
point_id INT NOT NULL,
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
max_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (point_id, slug),
INDEX idx_shard_spawn_point_types_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
-- holds the region's rectangles; `priority` and rect area are what resolved each
-- spawn point at build time, kept here so the admin drift check can re-derive.
CREATE TABLE IF NOT EXISTS shard_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
type VARCHAR(80) NULL, -- ServUO region class
priority INT NOT NULL DEFAULT 0,
parent VARCHAR(120) NULL, -- enclosing named region, if any
rects JSON NULL,
INDEX idx_shard_regions_facet (facet),
INDEX idx_shard_regions_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
CREATE TABLE IF NOT EXISTS shard_landmarks (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
grp VARCHAR(120) NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
INDEX idx_shard_landmarks_facet (facet),
INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
name VARCHAR(120) NOT NULL,
grp VARCHAR(80) NULL, -- spawn group; one active per group
type VARCHAR(80) NULL, -- '' when randomised per activation
random_type TINYINT(1) NOT NULL DEFAULT 0,
facet VARCHAR(40) NOT NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
radius INT NOT NULL DEFAULT 0,
label VARCHAR(120) NULL, -- resolved place name
INDEX idx_shard_champion_spawns_facet (facet)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) describing the artifact currently loaded: when it was
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
-- compares this against db/data/spawnAtlas.meta.json to report when the database
-- is behind the committed artifact.
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
payload JSON NOT NULL,
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
-- NOT applied, because it would remove a facet the site currently serves.
--
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
-- much as of a real map change, and boot cannot tell the two apart — so the
-- refresh is staged here for a human instead of being applied. Startup is never
-- blocked by it: the site comes up serving the atlas it already had.
--
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
-- keeps a multi-megabyte blob out of the database and guarantees the applied
-- atlas matches the tree as it is at approval time, not as it was at boot.
--
-- `rejected` is remembered against those exact source hashes so a declined
-- refresh does not re-prompt on every restart; changing the tree changes the
-- hashes and asks again.
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
payload JSON NOT NULL, -- source hashes + facet diff
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,59 @@
// ── module-uo · server entry point ─────────────────────────────────────────
//
// SPIKE SCOPE. Phase 1 carries only /api/v1/public/atlas/* out of core
// (MODULE_SYSTEM.md §2.7): six routes, DB-backed, no sidecar, no SSE, one boot
// hook. Phase 3 brings the rest — the other 12 router/controller files, the 7
// remaining model directories, the notification-stream catalog and the
// town-crier announce leg.
//
// Called ONCE, synchronously, during the website's app.js require. Everything
// here must therefore be synchronous and must not touch the database: the route
// manifest generator and the OpenAPI generator both require app.js with the
// pool pointed at a dead port, and a module that queried here would hang both
// (MODULE_API.md §2.2). Anything needing a live database goes in onBoot.
const core = require('./core')
module.exports = function register(ctx, api) {
// Park ctx before requiring anything that reads it. The requires below pull in
// the model layer, whose files resolve core lazily — but the ORDER still
// matters for the router, which is constructed at require time.
core.init(ctx)
/* eslint-disable global-require */
const atlasRouter = require('./router/atlas.router')
const atlas = require('./model/shardAtlas/shardAtlas.model')
/* eslint-enable global-require */
const log = ctx.log('boot')
// The URL is unchanged from when this router lived in core's
// router/v1/public/index.js — that is the point, and routes.manifest.json is
// the proof (MODULE_API.md §5.3).
api.registerRoutes({
public: { '/atlas': atlasRouter },
})
// Was server.js:92, an explicit call in core's start(). Re-derive the spawn
// atlas from the shard's own ServUO tree: the shard's maps change over its
// lifetime — facets get added, replaced or renamed — so the atlas is rebuilt
// on every boot rather than shipped as a snapshot that would silently go
// stale. Hash-gated, so an unchanged tree costs one read pass and no write.
//
// Best-effort by contract: no configured path, an unreadable mount or a
// malformed file must never stop the site coming up, and a refresh that would
// REMOVE a facet is staged for admin approval instead of being applied. So it
// is caught HERE rather than left to the loader — the loader's catch would be
// correct about the failure but wrong about the severity, marking the module
// startup_failed and 503-ing six routes that serve perfectly good stale data.
api.onBoot(async () => {
try {
const result = await atlas.refreshOnBoot()
log.info('spawn atlas refreshed', { status: result && result.status })
} catch (err) {
log.warn('spawn atlas refresh failed — serving whatever was last imported', {
error: err.message,
})
}
})
}

View File

@@ -1,4 +1,4 @@
const { pool, query } = require('../../utils/db')
const { pool, query } = require('../../core')
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
// empties and refills all six inside one transaction, and nothing else in the

View File

@@ -2,7 +2,7 @@ const fs = require('fs')
const path = require('path')
const db = require('./shardAtlas.db')
const settings = require('../settings/settings.model')
const { settings } = require('../../core')
const { slugify } = require('../../utils/spawnAtlasParse')
const {
AtlasSourceError,
@@ -11,7 +11,7 @@ const {
hashSources,
sameSources,
} = require('../../utils/spawnAtlasSource')
const log = require('../../utils/logger')('shardAtlas')
const log = require('../../core').logger('atlas')
// The spawn atlas, refreshed from the shard's own ServUO tree.
//

View File

@@ -0,0 +1,42 @@
const { query } = require('../../core')
const COLS = 'account, user_id, char_name, linked_at'
// Upsert a link. account is the PK, so a re-link moves the account to the new
// user (the sidecar already treats /link/confirm as authoritative).
async function upsert({ account, userId, charName }) {
await query(
`INSERT INTO shard_account_links (account, user_id, char_name)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
[account, userId, charName || null],
)
return getByAccount(account)
}
async function getByAccount(account) {
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
return rows[0] || null
}
const listByUser = (userId) =>
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
async function isOwnedBy(account, userId) {
const rows = await query(
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
[account, userId],
)
return rows.length > 0
}
const remove = (account, userId) =>
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
// Drop the mirror for an account regardless of which user held it — used to
// reconcile when the tie is severed at the source (an in-game [unlink →
// account.unlinked event, or a site-side DELETE /link/{account}).
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }

View File

@@ -0,0 +1,37 @@
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
// authoritative link (it tags the game account on /link/confirm); this model
// records it locally so the player portal can list links and enforce ownership.
const db = require('./shardLinks.db')
function toSafe(row) {
if (!row) return null
return {
account: row.account,
userId: row.user_id,
charName: row.char_name || null,
linkedAt: row.linked_at,
}
}
async function link({ account, userId, charName }) {
return toSafe(await db.upsert({ account, userId, charName }))
}
async function listForUser(userId) {
const rows = await db.listByUser(userId)
return rows.map(toSafe)
}
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
async function getByAccount(account) {
return toSafe(await db.getByAccount(account))
}
const unlink = (account, userId) => db.remove(account, userId)
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }

View File

@@ -0,0 +1,37 @@
const { query } = require('../../core')
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
// compiles a default for every known feature and merges stored rows over it, so
// a fresh install with an empty table behaves exactly as the site did pre-v3.
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
const getOne = (feature) =>
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
// {field: rung}; the caller has already stripped locked fields and validated
// every rung against the ladder.
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
query(
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
audience = VALUES(audience),
stream = VALUES(stream),
field_rules = VALUES(field_rules),
updated_by = VALUES(updated_by)`,
[
feature,
enabled ? 1 : 0,
audience,
stream ? 1 : 0,
fieldRules == null ? null : JSON.stringify(fieldRules),
updatedBy ?? null,
],
)
module.exports = { listAll, getOne, upsert }

View File

@@ -0,0 +1,44 @@
// ── Shard feature visibility (model) ───────────────────────────────────────
//
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
// feature catalog, the locked fields, the kind→feature map — lives in
// utils/shardVisibility.js; this file only reads and writes rows.
const db = require('./shardVisibility.db')
// The `field_rules` JSON column comes back as a string on the mariadb driver.
function parseRules(raw) {
if (raw == null) return {}
if (typeof raw === 'object') return raw
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
const toSafe = (row) =>
row && {
feature: row.feature,
enabled: !!row.enabled,
audience: row.audience,
stream: row.stream == null ? null : !!row.stream,
fieldRules: parseRules(row.field_rules),
updatedBy: row.updated_by,
updatedAt: row.updated_at,
}
async function listAll() {
const rows = await db.listAll()
return rows.map(toSafe)
}
async function getOne(feature) {
const rows = await db.getOne(feature)
return toSafe(rows[0])
}
const upsert = (entry) => db.upsert(entry)
module.exports = { listAll, getOne, upsert }

View File

@@ -21,10 +21,10 @@
// not projecting is a bug, and the cost of honouring it is one call per handler
// rather than a retrofit the first time a field needs gating.
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
const visibility = require('../../../utils/shardVisibility')
const atlas = require('../model/shardAtlas/shardAtlas.model')
const visibility = require('../utils/visibility')
const log = require('../../../utils/logger')('public-atlas')
const log = require('../core').logger('public-atlas')
const FEATURE = 'atlas'

View File

@@ -16,13 +16,17 @@
// The default audience is `anonymous`, so these gates are inert until an admin
// changes something.
const express = require('express')
const { param, query } = require('express-validator')
// express and express-validator come from core, never from a require here: this
// file lives outside server/, so Node's resolver would not find them, and a
// second express in the process would be a second Router prototype
// (docs/website/MODULE_API.md §2.3).
const core = require('../core')
const atlas = require('./atlas.controller')
const siteMode = require('../../../middleware/siteMode')
const validate = require('../../../middleware/validate')
const { requireFeature } = require('../../../utils/shardVisibility')
const { requireFeature } = require('../utils/visibility')
const { express, validator, middleware } = core
const { param, query } = validator
const { siteMode, validate } = middleware
const atlasRouter = express.Router()

View File

@@ -0,0 +1,60 @@
// ── Test harness: a fake ctx ───────────────────────────────────────────────
//
// A module's tests cannot require core — that is the whole zero-internal-imports
// rule (docs/website/MODULE_API.md §5.1), and it applies to test files too. So
// instead of stubbing core's modules the way core's own tests do, a module test
// hands `core.init()` a ctx it fabricated.
//
// That turns out to be the nicer story: the seam that exists so a module can be
// swapped onto a different core is the same seam that lets its tests run with no
// database, no express app and no settings table. Core's tests reach the same
// place by pointing the mariadb pool at a dead port; a module does not have to.
const core = require('../core')
/**
* Build and install a fake ctx. Every member is a stub the test can reassign.
* @param {object} [over] members to override, deep-merged one level
*/
function installFakeCtx(over = {}) {
const settings = new Map()
const ctx = {
moduleId: 'uo',
paths: { moduleRoot: require('path').join(__dirname, '..', '..') },
// Null, not the real packages: a module cannot resolve express from outside
// server/ (that is why ctx carries them at all), and these tests construct no
// router. A test that needs one passes the real ones in `over`.
express: null,
validator: null,
db: {
// Every test that needs a query result reassigns this.
query: async () => [],
pool: { getConnection: async () => { throw new Error('no pool in tests') } },
},
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
settings: {
get: async (key) => (settings.has(key) ? settings.get(key) : null),
set: async (key, value) => { settings.set(key, value) },
getInstanceName: async () => 'Test Shard',
},
auth: { getUserFromRequest: () => null },
push: { publish: async () => {} },
secretBox: { encrypt: (s) => s, decrypt: (s) => s },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
},
uploads: {},
posts: {},
...over,
}
core.init(ctx)
return ctx
}
module.exports = { installFakeCtx }

View File

@@ -15,7 +15,7 @@ const {
resolveFacetName,
slugify,
decodeEntities,
} = require('../src/utils/spawnAtlasParse')
} = require('../utils/spawnAtlasParse')
// These parsers are pure and fs-free precisely so this suite can run in CI,
// where there is no ServUO tree. Every fixture below is a literal excerpt of a

View File

@@ -1,6 +1,5 @@
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
// No dead-port pool trick here: a module test fabricates its ctx instead, so
// there is no database to point anywhere (see test/_ctx.js).
const fs = require('fs')
const os = require('os')
const path = require('path')
@@ -8,6 +7,10 @@ const path = require('path')
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const { installFakeCtx } = require('./_ctx')
installFakeCtx()
const {
AtlasSourceError,
aggregateCreatures,
@@ -16,13 +19,13 @@ const {
hashSources,
buildAtlas,
PARSER_VERSION,
} = require('../src/utils/spawnAtlasSource')
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
const atlasDb = require('../src/model/shardAtlas/shardAtlas.db')
const settings = require('../src/model/settings/settings.model')
const db = require('../src/utils/db')
after(() => db.close())
} = require('../utils/spawnAtlasSource')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
// The model captured this object at require time, so reassigning a method on it
// is how a test stubs core — the module equivalent of core's own tests
// monkey-patching a model.
const { settings } = require('../core')
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
//

View File

@@ -0,0 +1,435 @@
// ── Shard feature visibility ───────────────────────────────────────────────
//
// Admin-configurable, per-feature and per-field audience control over every
// shard-derived surface on the site. Replaces the hardcoded split that used to
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
//
// Design rules (docs/link/v3.md §3):
//
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
// is a dumb forwarder: it accepts frames, stores them, forwards them
// verbatim, and serves store-backed reads. It defines no audiences.
// • Every default reproduces the behavior that shipped before this module, so
// installing it changes nothing until an admin edits the config.
// • Two rules an admin CANNOT override:
// 1. `acct` / `webId` are admin-only, always. They are not in-game
// visible (unlike a character name) and are not configurable fields.
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
// Fail closed — this is what keeps the kind map a security boundary
// rather than a convenience filter.
//
// The audience ladder is ordered; each rung implies the ones below it.
const db = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const { auth } = require('../core')
const log = require('../core').logger('visibility')
// ── The ladder ─────────────────────────────────────────────────────────────
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
const RANK = new Map(LADDER.map((level, i) => [level, i]))
const isLevel = (level) => RANK.has(level)
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
// point: an unrecognised value must always lose. A single shared fallback cannot
// do that — whichever direction it picks, it fails open on one side. So:
//
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
//
// With one `rank()` defaulting to admin, a viewer level that fell through (a
// typo, a future rung this build doesn't know, a value from a caller that
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
// True when a viewer at `viewer` satisfies a requirement of `required`.
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
// Exported for tests/diagnostics; `meets` is what callers should use.
const rank = viewerRank
// ── Features ───────────────────────────────────────────────────────────────
//
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
// not listed here is visible whenever the feature itself is.
//
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
// frames nest actors (`leader.acct`), but several read models flatten them
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
// anonymous callers while the same account name was correctly stripped from the
// live `house.decay` frame.
//
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
// fail closed for shapes nobody has written yet.
const LOCKED_SUFFIXES = ['acct', 'webid']
const isLockedField = (key) => {
const k = String(key).toLowerCase()
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
}
const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
// `owner` is the actor object on the house.decay/house.update frames;
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
// REST read models. Both are listed so one rule covers the wire and the read
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
houses: {
audience: 'anonymous',
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
},
// /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung.
presence: { audience: 'anonymous', fields: { location: 'staff' } },
// ── New in v3. ──
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
atlas: { audience: 'anonymous', fields: {} },
// `name` is the ranked character's name inside points.board's `top` entries, and
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
// ("characterName"). projectValue matches on the literal JSON key, so a rule
// named for the field's meaning rather than its key silently does nothing — the
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
// leaderboards payload `name` can only be a character name: the board's own
// display name arrives as `nameString`/`nameNumber`.
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
// Shop name, owner character name and vendor location are already globally
// visible in-game via the stock Vendor Search gump, so publishing them is not
// a new disclosure — but they stay configurable so an admin can tighten them.
//
// `ownerName` and `location` were pre-wired here by Part A, before the frame
// existed; both were re-checked against the real `vendor.listing` and both are
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
// `location` is a NESTED object on the wire and on the read model precisely so
// that one rule hides map, coordinates, region and house together — five flat
// keys would be five rules that drift apart.
//
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
// lists both: an admin who hides the owner's name and is left with a serial
// that every other board resolves back to that name has not hidden anything.
market: {
audience: 'anonymous',
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
},
}
const FEATURE_NAMES = Object.keys(FEATURES)
const isFeature = (name) => Object.hasOwn(FEATURES, name)
// ── Kind → feature ─────────────────────────────────────────────────────────
//
// Every event kind that may ever leave the admin channel must appear here.
// Anything else is admin-only by omission (rule 2). This map is seeded from
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
// same kinds it did — now attributed to a feature that an admin can re-gate.
const KIND_FEATURE = new Map(
Object.entries({
// status / lifecycle
'server.hello': 'status',
'server.shutdown': 'status',
'server.crashed': 'status',
'economy.supply': 'status',
// activity feed
'player.death': 'activity',
'player.murdered': 'activity',
'mob.killed': 'activity',
'quest.complete': 'activity',
'skill.gain': 'activity',
'fame.change': 'activity',
'karma.change': 'activity',
'mob.login': 'activity',
'mob.logout': 'activity',
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',
// house.decay is the IDOC signal the public Houses page renders. The full
// registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses',
// v3
'world.ruleset': 'ruleset',
'points.board': 'leaderboards',
// vendor.listing IS mapped, but the market feature ships with its stream
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
// inventories would be the site's biggest bandwidth consumer and no page
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
}),
)
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
// are unaffected; only the live stream is suppressed.
const DEFAULT_STREAM_OFF = new Set(['market'])
// Back-compat: the set of kinds that reach an anonymous viewer under the default
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
// this. Derived from the map above rather than hand-maintained, so the two can
// no longer drift.
const PUBLIC_KINDS = new Set(
[...KIND_FEATURE.entries()]
.filter(([, feature]) => {
if (DEFAULT_STREAM_OFF.has(feature)) return false
return FEATURES[feature].audience === 'anonymous'
})
.map(([kind]) => kind),
)
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
const CONFIG_TTL_MS = 5000
let cache = null
let cachedAt = 0
// Merge a stored row over its compiled default. Unknown feature names in the DB
// are ignored (a stale row from a removed feature must not resurrect it), and an
// invalid rung falls back to the default rather than failing open.
function applyRow(name, row) {
const base = FEATURES[name]
const audience = isLevel(row?.audience) ? row.audience : base.audience
const fields = { ...base.fields }
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
if (isLockedField(field)) continue // rule 1: not configurable
if (isLevel(level)) fields[field] = level
}
return {
enabled: row ? !!row.enabled : true,
audience,
fields,
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
}
}
function compileDefaults() {
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
return out
}
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
// "what the site did before" rather than to "everything is public".
async function getConfig() {
const now = Date.now()
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
try {
const rows = await db.listAll()
const byName = new Map(rows.map((r) => [r.feature, r]))
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
cache = out
cachedAt = now
} catch (err) {
log.error('getConfig; falling back to defaults', err)
cache = cache || compileDefaults()
cachedAt = now
}
return cache
}
const invalidate = () => {
cache = null
cachedAt = 0
}
// ── Viewer level ───────────────────────────────────────────────────────────
//
// anonymous no session
// logged_in authenticated, no linked game account
// player authenticated with a linked game account
// staff admin | moderator — the same set as the existing `modAccess` gate.
// `editor` is a CONTENT role with no shard privilege today, so it
// resolves by link status like any other member; mapping it to staff
// here would silently widen what editors can see.
// admin admin
//
// Staff always satisfy the `player` rung (rank order guarantees it) even without
// a linked account, matching the existing rule that /player/* is role-agnostic
// self-service.
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
// (or newly relinked) account must not keep the old answer for long. Anonymous,
// staff and admin callers short-circuit before this runs, so the lookup only
// costs a query on the logged-in-member path.
const LINK_TTL_MS = CONFIG_TTL_MS
const linkCache = new Map() // userId → { hasLink, at }
async function hasLinkedAccount(userId) {
const hit = linkCache.get(userId)
const now = Date.now()
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
let hasLink = false
try {
const links = await shardLinks.listForUser(userId)
hasLink = Array.isArray(links) && links.length > 0
} catch (err) {
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
}
linkCache.set(userId, { hasLink, at: now })
return hasLink
}
// Drop a user's cached link status (called when a link is created or removed so
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
const forgetUser = (userId) => linkCache.delete(userId)
async function viewerLevel(req) {
const viewer = req.user || auth.getUserFromRequest(req)
if (!viewer) return 'anonymous'
if (viewer.role === 'admin') return 'admin'
if (viewer.role === 'moderator') return 'staff'
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
}
// ── Enforcement ────────────────────────────────────────────────────────────
// Route gate. 404 when the feature is disabled (do not leak that it exists);
// 403 when it exists but the viewer sits below its audience. Stashes the
// resolved level on the request so controllers can project without re-resolving.
function requireFeature(name) {
return async (req, res, next) => {
try {
const config = await getConfig()
const feature = config[name]
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
const level = await viewerLevel(req)
req.viewerLevel = level
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
return next()
} catch (err) {
log.error(`requireFeature(${name})`, err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
}
// Strip the fields a viewer at `level` may not see. Applies the locked rules
// first (so acct/webId can never survive below admin), then the feature's
// configured field rules. Recurses into arrays and nested objects because the
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
// instance is a value, not a bag of fields: rebuilding one key-by-key would
// return `{}` (a Date has no enumerable own properties), which is how the DB-
// backed read models — whose rows carry real Date columns — differ from the
// pure-JSON wire frames the projection was first written against.
const isPlainObject = (v) => {
if (v === null || typeof v !== 'object') return false
const proto = Object.getPrototypeOf(v)
return proto === Object.prototype || proto === null
}
function projectValue(value, rules, level) {
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
if (!isPlainObject(value)) return value
const out = {}
for (const [key, v] of Object.entries(value)) {
// Locked fields are checked by meaning first, so no configured rule (and no
// flattened spelling) can widen them past `admin`.
const required = isLockedField(key) ? 'admin' : rules[key]
if (required && !meets(level, required)) continue
out[key] = projectValue(v, rules, level)
}
return out
}
// Project a payload for one feature. `level` defaults to admin-equivalent only
// when explicitly passed; callers should always pass a resolved level.
function projectFeature(name, payload, level, config) {
const feature = config?.[name]
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
return projectValue(payload, rules, level)
}
// Convenience for controllers: resolve config once, project, return.
async function project(name, payload, req) {
const config = await getConfig()
const level = req.viewerLevel || (await viewerLevel(req))
return projectFeature(name, payload, level, config)
}
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
function kindVisibleTo(kind, level, config) {
if (level === 'admin') return true
const name = KIND_FEATURE.get(kind)
if (!name) return false // rule 2: unmapped ⇒ admin-only
const feature = config?.[name]
if (!feature || !feature.enabled || !feature.stream) return false
return meets(level, feature.audience)
}
// The event kinds a viewer at `level` may read under the CURRENT config. This is
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
// this kind, given what the admin has configured?".
//
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
// feature whose live firehose is off (market) is still readable from the stored
// history. Unmapped kinds are absent by construction (rule 2).
function visibleKinds(level, config) {
return [...KIND_FEATURE.entries()]
.filter(([, name]) => {
const feature = config?.[name]
return !!feature && feature.enabled && meets(level, feature.audience)
})
.map(([kind]) => kind)
}
// The features a viewer at `level` can actually see — drives SPA nav so it never
// renders a link that would 403.
function visibleFeatures(level, config) {
return FEATURE_NAMES.filter((name) => {
const feature = config[name]
return feature.enabled && meets(level, feature.audience)
})
}
module.exports = {
LADDER,
FEATURES,
FEATURE_NAMES,
LOCKED_FIELDS,
KIND_FEATURE,
PUBLIC_KINDS,
DEFAULT_STREAM_OFF,
isLevel,
isFeature,
isLockedField,
rank,
meets,
getConfig,
invalidate,
compileDefaults,
viewerLevel,
forgetUser,
requireFeature,
projectFeature,
project,
kindVisibleTo,
visibleKinds,
visibleFeatures,
}

View File

@@ -1135,121 +1135,6 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- `facets` is a per-facet point count, so the facet filter and "where does this
-- live" both answer without touching shard_spawn_points.
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
total INT NOT NULL DEFAULT 0,
points INT NOT NULL DEFAULT 0,
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
-- creature art: sprites live in the operator's own client .mul/.uop files and
-- are theirs to extract and place under uploads/atlas/. The UI renders without
-- art when this is NULL, which is the normal case.
art VARCHAR(255) NULL,
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
-- and FULLTEXT's min-token-length would break searches for names like "orc".
INDEX idx_shard_spawn_creatures_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per spawner. `region`/`landmark` are the resolved place name — the
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
-- the resolved display string (region, else landmark, else 'Wilderness').
CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
max_count INT NOT NULL DEFAULT 0,
min_delay INT NOT NULL DEFAULT 0,
max_delay INT NOT NULL DEFAULT 0,
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
tod_end INT NOT NULL DEFAULT 0,
tod_mode INT NOT NULL DEFAULT 0,
region VARCHAR(120) NULL,
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
-- types (a single Trammel point spawns six), each with its own max. This is how
-- /atlas/creatures/:slug finds the places a creature appears.
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
point_id INT NOT NULL,
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
max_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (point_id, slug),
INDEX idx_shard_spawn_point_types_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
-- holds the region's rectangles; `priority` and rect area are what resolved each
-- spawn point at build time, kept here so the admin drift check can re-derive.
CREATE TABLE IF NOT EXISTS shard_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
type VARCHAR(80) NULL, -- ServUO region class
priority INT NOT NULL DEFAULT 0,
parent VARCHAR(120) NULL, -- enclosing named region, if any
rects JSON NULL,
INDEX idx_shard_regions_facet (facet),
INDEX idx_shard_regions_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
CREATE TABLE IF NOT EXISTS shard_landmarks (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
grp VARCHAR(120) NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
INDEX idx_shard_landmarks_facet (facet),
INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
name VARCHAR(120) NOT NULL,
grp VARCHAR(80) NULL, -- spawn group; one active per group
type VARCHAR(80) NULL, -- '' when randomised per activation
random_type TINYINT(1) NOT NULL DEFAULT 0,
facet VARCHAR(40) NOT NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
radius INT NOT NULL DEFAULT 0,
label VARCHAR(120) NULL, -- resolved place name
INDEX idx_shard_champion_spawns_facet (facet)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- UO's localization table: cliloc id -> display string. Items carry a
-- `LabelNumber` rather than a name, so without this the site can only render
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
@@ -1283,41 +1168,6 @@ CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) describing the artifact currently loaded: when it was
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
-- compares this against db/data/spawnAtlas.meta.json to report when the database
-- is behind the committed artifact.
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
payload JSON NOT NULL,
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
-- NOT applied, because it would remove a facet the site currently serves.
--
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
-- much as of a real map change, and boot cannot tell the two apart — so the
-- refresh is staged here for a human instead of being applied. Startup is never
-- blocked by it: the site comes up serving the atlas it already had.
--
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
-- keeps a multi-megabyte blob out of the database and guarantees the applied
-- atlas matches the tree as it is at approval time, not as it was at boot.
--
-- `rejected` is remembered against those exact source hashes so a declined
-- refresh does not re-prompt on every restart; changing the tree changes the
-- hashes and asks again.
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
payload JSON NOT NULL, -- source hashes + facet diff
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.

View File

@@ -19,6 +19,7 @@ const createLogger = require('./utils/logger')
const htmlShell = require('./utils/htmlShell')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
const modules = require('./modules/loader')
const httpLog = createLogger('http')
const errLog = createLogger('error')
@@ -108,6 +109,34 @@ app.use(
}),
)
// ── Installed modules ─────────────────────────────────────────────────
// Discovered synchronously from the filesystem, with no database (see
// modules/loader.js for why that is not negotiable). The scan has already run by
// the time the routers below are required; calling it here makes the ordering
// explicit rather than incidental.
modules.scan()
// A module's prebuilt client chunk, served same-origin at /modules/<id>/*.
// Same-origin is the whole point: CSP is `script-src 'self'` with no
// 'unsafe-inline' (config/csp.js:49), so this loads with no nonce and no import
// map — see docs/website/MODULE_API.md §3.1.
//
// **One static mount PER MODULE, rooted at that module's client dist** — never
// one mount over the modules directory. A module holds its server source, its
// module.json and its schema fragment alongside the client build; a single
// `express.static(modulesDir)` would publish all of it. This serves exactly the
// directory the module nominated as its browser bundle and nothing above it.
for (const mod of modules.list()) {
if (!mod.clientDir || !fs.existsSync(mod.clientDir)) continue
app.use(
`/modules/${mod.id}`,
express.static(mod.clientDir, {
index: false,
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
}),
)
}
// ── API docs (Swagger UI) ─────────────────────────────────────────────
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
// is generated from route annotations by `npm run swagger` (server/swagger/).

View File

@@ -0,0 +1,495 @@
// ── The module loader ──────────────────────────────────────────────────────
//
// SPIKE (docs/website/MODULE_SYSTEM.md §2.7 Phase 1). This is the smallest
// loader that can carry /api/v1/public/atlas/* out of core and prove the
// contract in docs/website/MODULE_API.md. Phase 2 rebuilds it properly with the
// installed_modules table, the full state machine and the admin panel behind it.
//
// The one property this file exists to guarantee, and the reason it looks the
// way it does:
//
// **The filesystem is the mounting source of truth, and mounting is
// SYNCHRONOUS.** `scripts/routeManifest.js:38` and `swagger/swagger.js:29`
// both require app.js with the pool pointed at a dead port. A loader that
// awaited a database row before mounting would make every module route
// invisible to the frozen-URL-surface test (§1.12). So: readdirSync at
// require time, no database, no promises.
//
// A module that fails ANYWHERE in this file fails alone. Nothing here may throw
// past its own try/catch — a bad module must cost the site its routes, never its
// boot.
const fs = require('fs')
const path = require('path')
const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const log = require('../utils/logger')('modules')
const REPO_ROOT = path.join(__dirname, '..', '..', '..')
const MODULES_DIR = process.env.MODULES_DIR || path.join(REPO_ROOT, 'modules')
// One segment, lowercase, no parameters. A module prefix that could contain a
// `/` or a `:` would let a module reach outside the slot it was given.
const ID = /^[a-z][a-z0-9-]{1,31}$/
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
const TIERS = ['public', 'admin', 'player']
const MANIFEST_KEYS = new Set([
'id', 'name', 'version', 'coreApi', 'server', 'client',
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
])
// Prefixes core itself owns, per tier. A module may not take one of these.
// Hardcoded for the spike; Phase 2 derives it from the tier mount tables so it
// cannot drift the first time core adds a capability router.
const CORE_PREFIXES = {
public: ['/posts', '/wiki', '/pages', '/shard'],
admin: [
'/account', '/users', '/invites', '/auth', '/moderation', '/bot-activity',
'/activity', '/posts', '/uploads', '/wiki', '/pages', '/shard', '/uo-link',
'/email', '/discord-bot', '/settings',
],
player: ['/account', '/shard', '/appeals'],
}
// id → record. Populated by scan(), read by mountInto/boot/shutdown/list.
const modules = new Map()
let scanned = false
// ── ctx ────────────────────────────────────────────────────────────────────
// Everything a module may reach in core, and nothing else (MODULE_API.md §2.3).
// Required lazily inside the factory rather than at file scope: this module is
// required by app.js, and hoisting these to the top would make the DB pool, the
// settings model and the upload directory startup-time dependencies of the
// loader itself.
function buildCtx(id, moduleRoot) {
/* eslint-disable global-require */
// The shared SERVER dependencies — the exact counterpart of window.__rg's
// react/react-dom/react-router on the client, and load-bearing for the same
// two reasons.
//
// 1. A module lives at <repo>/modules/<id>/, OUTSIDE server/, so Node's
// resolver walks up from there and never sees server/node_modules. A
// module that required 'express' itself would fail to load — which is
// exactly how this was discovered.
// 2. Even if it resolved, a second copy of express in the process is a
// second Router prototype and a second set of instanceof checks. One
// express, owned by core, is the same rule as one React.
//
// The consequence for a module author is the same on both sides: declare these
// external, never bundle them, take them from what core hands you.
const express = require('express')
const validator = require('express-validator')
const db = require('../utils/db')
const settings = require('../model/settings/settings.model')
const posts = require('../model/posts/posts.model')
const auth = require('../utils/auth')
const pushDispatch = require('../utils/pushDispatch')
const secretBox = require('../utils/secretBox')
const createLogger = require('../utils/logger')
const { requireAuth, requireRole } = require('../auth/session.middleware')
const siteMode = require('../middleware/siteMode')
const validate = require('../middleware/validate')
const noindex = require('../middleware/noindex')
const uploads = require('../router/v1/admin/imageUpload')
/* eslint-enable global-require */
// Narrowed on purpose (MODULE_API.md §2.3): utils/auth also re-exports
// signToken/setAuthCookie/the TOTP challenge primitives, and minting a session
// is core's job. A module that needs an identity needs to READ one.
const ctx = {
moduleId: id,
paths: { moduleRoot },
express,
validator,
db: { query: db.query, pool: db.pool },
log: (namespace) => createLogger(namespace ? `${id}:${namespace}` : id),
settings: {
get: settings.get,
set: settings.set,
getInstanceName: settings.getInstanceName,
},
auth: { getUserFromRequest: auth.getUserFromRequest },
push: { publish: pushDispatch.publish },
secretBox: { encrypt: secretBox.encrypt, decrypt: secretBox.decrypt },
middleware: { requireAuth, requireRole, siteMode, validate, noindex },
uploads,
posts: {
listAll: posts.listAll,
getById: posts.getById,
linkAnnounceJob: posts.linkAnnounceJob,
markAnnounced: posts.markAnnounced,
},
}
// A guard against accident, not against a hostile module — the boundary is
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
return Object.freeze(ctx)
}
// ── The registration api ───────────────────────────────────────────────────
// Collects what the module registers so validation can compare it against what
// module.json DECLARED. Declaration is the contract; a module that registers a
// prefix it did not declare is rejected, because module.json is what the admin
// panel, the collision check and the reviewer all read.
function buildApi(record) {
const once = (name) => {
if (record.called.has(name)) throw new Error(`${name}() called twice`)
record.called.add(name)
}
return {
registerRoutes(mounts) {
once('registerRoutes')
if (!mounts || typeof mounts !== 'object') throw new Error('registerRoutes: expected an object')
for (const [tier, byPrefix] of Object.entries(mounts)) {
if (!TIERS.includes(tier)) throw new Error(`registerRoutes: unknown tier "${tier}"`)
for (const [prefix, router] of Object.entries(byPrefix)) {
if (!PREFIX.test(prefix)) throw new Error(`registerRoutes: bad prefix "${prefix}"`)
if (typeof router !== 'function') throw new Error(`registerRoutes: ${tier}${prefix} is not a router`)
record.routes[tier].set(prefix, router)
}
}
},
// Declared for contract completeness; the spike registers none of these, and
// an accepting no-op would let a module think it had registered something.
registerExtension() { throw new Error('registerExtension: not implemented in the Phase 1 spike') },
registerNotificationStreams() { throw new Error('registerNotificationStreams: not implemented in the Phase 1 spike') },
registerAnnounceLeg() { throw new Error('registerAnnounceLeg: not implemented in the Phase 1 spike') },
onBoot(fn) {
once('onBoot')
if (typeof fn !== 'function') throw new Error('onBoot: expected a function')
record.onBoot = fn
},
onShutdown(fn) {
once('onShutdown')
if (typeof fn !== 'function') throw new Error('onShutdown: expected a function')
record.onShutdown = fn
},
}
}
// ── Validation ─────────────────────────────────────────────────────────────
// Table names a module may create despite not carrying its own id as a prefix.
//
// module-uo's twenty-seven tables predate the module system by two years, and
// renaming live tables is a data migration this workstream deliberately does not
// do (MODULE_SYSTEM.md §1.6). Grandfathering them by an explicit, per-module
// allowlist keeps the prefix rule real for every module written after this one —
// the alternative, dropping the rule, would leave the first name collision to be
// discovered by a module silently adopting someone else's table.
const LEGACY_TABLE_PREFIXES = { uo: ['shard_', 'uo_link_'] }
const CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/gi
/** Table names core's own schema.sql declares — a module may not touch these. */
let coreTables = null
function coreTableNames() {
if (coreTables) return coreTables
coreTables = new Set()
try {
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8')
for (const m of sql.matchAll(CREATE_TABLE)) coreTables.add(m[1].toLowerCase())
} catch (err) {
log.warn('could not read core schema for the table-collision check', { message: err.message })
}
return coreTables
}
function checkTableNames(dir, manifest) {
const file = path.join(dir, manifest.schema)
const sql = fs.readFileSync(file, 'utf8')
const allowed = LEGACY_TABLE_PREFIXES[manifest.id] || []
const core = coreTableNames()
for (const m of sql.matchAll(CREATE_TABLE)) {
const table = m[1].toLowerCase()
if (core.has(table)) throw new Error(`schema fragment declares core table "${table}"`)
for (const other of modules.values()) {
if (other.tables && other.tables.has(table)) {
throw new Error(`schema fragment declares "${table}", already owned by module "${other.id}"`)
}
}
const prefixed = table.startsWith(`${manifest.id}_`) || allowed.some((p) => table.startsWith(p))
if (!prefixed) {
throw new Error(`schema fragment table "${table}" is not prefixed "${manifest.id}_"`)
}
}
}
/** Record which tables a module owns, so the next module can be checked against it. */
function tablesOf(dir, manifest) {
if (!manifest.schema) return new Set()
const sql = fs.readFileSync(path.join(dir, manifest.schema), 'utf8')
return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
}
function readManifest(dir, id) {
const file = path.join(dir, 'module.json')
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
for (const key of Object.keys(manifest)) {
// Rejected, not ignored: a typo'd key must be a loud failure rather than a
// silently inert setting the operator believes they configured.
if (!MANIFEST_KEYS.has(key)) throw new Error(`unknown key "${key}" in module.json`)
}
if (!ID.test(manifest.id || '')) throw new Error(`invalid id "${manifest.id}"`)
if (manifest.id !== id) throw new Error(`id "${manifest.id}" does not match directory "${id}"`)
if (!manifest.version) throw new Error('missing version')
if (!manifest.coreApi) throw new Error('missing coreApi')
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
throw new Error(`needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
}
if (manifest.schema && !manifest.purge) {
// A module that can create tables and cannot drop them leaves an operator
// with orphaned data and no supported way to remove it.
throw new Error('declares schema but no purge')
}
if (manifest.schema) checkTableNames(dir, manifest)
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
if (!TIERS.includes(tier)) throw new Error(`unknown tier "${tier}" in mounts`)
for (const prefix of prefixes) {
if (!PREFIX.test(prefix)) throw new Error(`bad prefix "${prefix}" in mounts.${tier}`)
if (CORE_PREFIXES[tier].includes(prefix)) throw new Error(`prefix ${tier}${prefix} is owned by core`)
for (const other of modules.values()) {
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
throw new Error(`prefix ${tier}${prefix} already registered by module "${other.id}"`)
}
}
}
}
return manifest
}
// What the module registered must equal what it declared — in both directions.
function checkDeclared(record) {
const declared = record.manifest.mounts || {}
for (const tier of TIERS) {
const want = new Set(declared[tier] || [])
const got = new Set(record.routes[tier].keys())
for (const p of got) if (!want.has(p)) throw new Error(`registered ${tier}${p} without declaring it`)
for (const p of want) if (!got.has(p)) throw new Error(`declared ${tier}${p} but never registered it`)
}
}
// ── Scan ───────────────────────────────────────────────────────────────────
/**
* Discover, validate and register every module under MODULES_DIR. Synchronous,
* filesystem-only, and safe to call when the directory does not exist. Called
* once from app.js at require time; a second call is a no-op.
*/
function scan() {
if (scanned) return
scanned = true
let entries = []
try {
entries = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort() // alphabetical: there is no dependency resolution, and any other
// order would imply a precedence nothing computes (§4.2)
} catch {
return // no modules directory is the normal case for a bare core
}
for (const id of entries) {
const dir = path.join(MODULES_DIR, id)
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
const record = {
id,
dir,
manifest: null,
routes: { public: new Map(), admin: new Map(), player: new Map() },
tables: new Set(),
called: new Set(),
onBoot: null,
onShutdown: null,
state: 'installed',
reason: null,
}
try {
record.manifest = readManifest(dir, id)
record.tables = tablesOf(dir, record.manifest)
if (record.manifest.server) {
const entry = path.join(dir, record.manifest.server)
// eslint-disable-next-line global-require, import/no-dynamic-require
const register = require(entry)
if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`)
register(buildCtx(id, dir), buildApi(record))
checkDeclared(record)
}
record.state = 'registered'
modules.set(id, record)
log.info(`registered module "${id}" v${record.manifest.version}`, {
mounts: record.manifest.mounts,
})
} catch (err) {
// A failure here is BEFORE any route was mounted, so this module's routes
// and nav are simply absent and the site comes up without it (§4.4).
record.state = 'startup_failed'
record.reason = err.message
record.manifest = record.manifest || { id, version: 'unknown' }
modules.set(id, record)
log.error(`module "${id}" failed to load — continuing without it`, { reason: err.message })
}
}
}
// ── Mounting ───────────────────────────────────────────────────────────────
/**
* Mount every registered module's routers for one tier onto that tier's router.
* Called from router/v1/{public,admin,player}/index.js, after core's own mounts
* so a module can never shadow a core prefix even if the collision check above
* were somehow bypassed.
*/
function mountInto(tier, tierRouter) {
scan()
for (const record of modules.values()) {
if (record.state !== 'registered' && record.state !== 'started') continue
for (const [prefix, router] of record.routes[tier]) {
// The dispatch guard. A module that failed AFTER mounting (schema replay,
// onBoot) keeps its URLs — so routes.manifest.json does not depend on
// whether a boot hook happened to succeed on the generating machine — but
// answers 503 rather than serving half-initialised data (§4.4).
tierRouter.use(prefix, (req, res, next) => {
if (record.state === 'startup_failed') {
return res.status(503).json({ message: 'Module unavailable' })
}
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
return next()
}, router)
}
}
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
/** Read every registered module's schema fragment, in scan order. */
function schemaFragments() {
scan()
const out = []
for (const record of modules.values()) {
if (record.state !== 'registered' || !record.manifest.schema) continue
const file = path.join(record.dir, record.manifest.schema)
try {
out.push({ id: record.id, sql: fs.readFileSync(file, 'utf8') })
} catch (err) {
markFailed(record.id, `schema fragment unreadable: ${err.message}`)
log.error(`module "${record.id}" schema fragment unreadable`, { reason: err.message })
}
}
return out
}
/**
* Move a module to `startup_failed` with a reason. Called by whoever ran the
* step that failed — ensureSchema() replays the fragments, so it is the only
* thing that can know a fragment threw.
*
* Failing here is a POST-mount failure: the routes stay mounted and the dispatch
* guard turns them into 503s, which is what keeps routes.manifest.json
* independent of whether a boot step succeeded on the generating machine (§4.4).
*/
function markFailed(id, reason) {
const record = modules.get(id)
if (!record) return
record.state = 'startup_failed'
record.reason = reason
}
/**
* Run every registered module's onBoot. Called from server.js AFTER
* ensureSchema() and seedDefaults() (so a module's own tables exist) and BEFORE
* the listener binds. Individually try/caught: a hook that throws costs that
* module its `started` state and nothing else.
*/
async function boot() {
scan()
for (const record of modules.values()) {
if (record.state !== 'registered') continue
try {
if (record.onBoot) await record.onBoot(buildCtx(record.id, record.dir))
record.state = 'started'
log.info(`module "${record.id}" started`)
} catch (err) {
record.state = 'startup_failed'
record.reason = `onBoot: ${err.message}`
log.error(`module "${record.id}" onBoot failed — its routes will answer 503`, {
reason: err.message,
})
}
}
}
const SHUTDOWN_BUDGET_MS = 5000
/** Run onShutdown in reverse registration order, bounded, never throwing. */
async function shutdown() {
const records = [...modules.values()].reverse()
for (const record of records) {
if (record.state !== 'started' || !record.onShutdown) continue
try {
await Promise.race([
record.onShutdown(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timed out')), SHUTDOWN_BUDGET_MS).unref()),
])
} catch (err) {
log.warn(`module "${record.id}" onShutdown failed`, { reason: err.message })
}
}
}
// ── Introspection ──────────────────────────────────────────────────────────
/**
* What GET /api/v1/public/modules, the HTML shell and the admin panel read.
*
* `clientDir` and `entryUrl` are split deliberately: app.js needs the absolute
* directory to serve statically, and it must be the DIST directory rather than
* the module root — a module keeps its server source, its module.json and its
* schema fragment alongside the client build, and one static mount over the
* module root would publish all of them.
*/
function list() {
scan()
return [...modules.values()].map((r) => {
const entry = r.manifest.client && r.manifest.client.entry
return {
id: r.id,
name: r.manifest.name || r.id,
version: r.manifest.version,
state: r.state,
reason: r.reason,
capabilities: r.manifest.capabilities || [],
// e.g. entry "client/dist/entry.js" → dir <root>/client/dist, url /modules/uo/entry.js
clientDir: entry ? path.join(r.dir, path.dirname(entry)) : null,
entryUrl: entry ? `/modules/${r.id}/${path.basename(entry)}` : null,
}
})
}
/** Absolute path of the modules directory. */
const dir = () => MODULES_DIR
// Test seam: the scan is memoised, and a test that points MODULES_DIR somewhere
// else needs to be able to redo it.
function _reset() {
modules.clear()
scanned = false
}
module.exports = {
scan, mountInto, schemaFragments, markFailed, boot, shutdown, list, dir, _reset, MODULES_DIR,
}

View File

@@ -0,0 +1,47 @@
// A deliberately tiny semver range check — enough for `coreApi` and no more.
//
// Supports `*`, an exact `x.y.z`, `^x.y.z` and `~x.y.z`. That is the whole
// grammar a module manifest is allowed to use (MODULE_API.md §1.1), so pulling
// in the `semver` package for it would add a dependency to the server for a
// twenty-line job. A range this parser does not understand is REJECTED rather
// than assumed to match — an unparseable range must not silently load a module
// against an API it was never tested on.
const PARTS = /^(\d+)\.(\d+)\.(\d+)$/
function parse(version) {
const m = PARTS.exec(String(version).trim())
if (!m) return null
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
}
const gte = (a, b) => {
if (a.major !== b.major) return a.major > b.major
if (a.minor !== b.minor) return a.minor > b.minor
return a.patch >= b.patch
}
/**
* Does `version` satisfy `range`?
* @param {string} version an exact x.y.z
* @param {string} range `*` | `x.y.z` | `^x.y.z` | `~x.y.z`
* @returns {boolean} false for anything unparseable, on either side
*/
function satisfies(version, range) {
const v = parse(version)
if (!v) return false
const raw = String(range).trim()
if (raw === '*') return true
const op = raw[0] === '^' || raw[0] === '~' ? raw[0] : ''
const b = parse(op ? raw.slice(1) : raw)
if (!b) return false
if (op === '') return v.major === b.major && v.minor === b.minor && v.patch === b.patch
if (!gte(v, b)) return false
// ^ allows minor+patch within the same major; ~ allows patch within the same minor.
if (op === '^') return v.major === b.major
return v.major === b.major && v.minor === b.minor
}
module.exports = { satisfies, parse }

View File

@@ -0,0 +1,14 @@
// The module API version — the single number a module's `coreApi` range is
// checked against (docs/website/MODULE_API.md §1.1).
//
// Bump minor when a member is ADDED to ctx or a new register* call appears;
// major when one is removed, its signature changes, or its behaviour changes
// without a signature change. A core-internal refactor behind an unchanged
// member is not a bump.
//
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
const MODULE_API_VERSION = '1.0.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -15,6 +15,7 @@ const express = require('express')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const modules = require('../../../modules/loader')
const accountRouter = require('./account.router')
const usersRouter = require('./users.router')
@@ -74,6 +75,11 @@ adminRouter.use('/email', emailRouter)
adminRouter.use('/discord-bot', discordBotRouter)
adminRouter.use('/settings', settingsRouter)
// Installed modules' admin routers. Already behind this group's
// noindex/isLoggedIn/staffOnly gate — a module adds per-route gates on top and
// never re-implements the tier gate (docs/website/MODULE_API.md §2.4).
modules.mountInto('admin', adminRouter)
// The two singletons that own no path segment of their own: GET /dashboard and
// PUT /site-mode. Mounted at the group root, last, exactly where the residual
// admin.routes.js used to sit — safe because dashboard.router.js declares no

View File

@@ -34,7 +34,7 @@ settingsRouter.put(
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Update site settings (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides carry only label/order/hidden/group; whether a key names a route the nav declares is settled client-side at merge time.'
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -15,7 +15,17 @@
// admin needs to be told what is wrong with their path, and a 500 says only
// "something broke".
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
// ⚠ SPIKE ARTIFACT — core reaching INTO a module. Phase 1 carries only the
// PUBLIC atlas routes out of core (MODULE_SYSTEM.md §2.7); these five admin
// routes live at /admin/shard/atlas/*, inside the `/shard` prefix that core
// still owns, so the module cannot take them without either colliding with core
// or changing a URL — and routes.manifest.json must not move.
//
// So this one import crosses the boundary in the core → module direction. It is
// not the direction the zero-imports rule forbids (a module must not reach into
// core), but it is still wrong, and it is precisely what Phase 3 fixes by moving
// the whole `/shard` admin prefix at once. Recorded here rather than hidden.
const atlas = require('../../../../../modules/uo/server/model/shardAtlas/shardAtlas.model')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('admin-shard-atlas')

View File

@@ -21,6 +21,7 @@ const express = require('express')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const modules = require('../../../modules/loader')
const accountRouter = require('./account.router')
const shardRouter = require('./shard.router')
@@ -40,4 +41,8 @@ playerRouter.use('/account', accountRouter)
playerRouter.use('/shard', shardRouter)
playerRouter.use('/appeals', appealsRouter)
// Installed modules' player routers, already behind this group's
// noindex/requireAuth gate.
modules.mountInto('player', playerRouter)
module.exports = playerRouter

View File

@@ -17,11 +17,12 @@
const express = require('express')
const modules = require('../../../modules/loader')
const postsRouter = require('./posts.router')
const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const shardRouter = require('./shard.router')
const atlasRouter = require('./atlas.router')
const siteRouter = require('./site.router')
const publicRouter = express.Router()
@@ -33,11 +34,16 @@ publicRouter.use('/wiki', wikiRouter)
publicRouter.use('/pages', pagesRouter)
// Live shard data, never site-mode gated.
publicRouter.use('/shard', shardRouter)
// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree
// rather than fetched from the sidecar. Deliberately not under /shard — nothing
// here depends on the bridge — and site-mode gated per route like the content
// routers above, which is the other half of that distinction.
publicRouter.use('/atlas', atlasRouter)
// Installed modules' public routers, each at the prefix it declared in its
// module.json. Mounted AFTER core's own prefixes so a module can never shadow
// one even if the loader's collision check were somehow bypassed, and BEFORE the
// root-mounted siteRouter below for the same reason that one is mounted last.
//
// The spawn atlas used to sit here as `/atlas`; it is now module-uo's, which is
// what the Phase 1 spike is proving (docs/website/MODULE_API.md). The URL is
// unchanged — routes.manifest.json is the proof.
modules.mountInto('public', publicRouter)
// The four singletons that own no path segment of their own: /settings, /status,
// /version and /contact. Mounted at the group root, last — safe only because

View File

@@ -14,7 +14,7 @@ const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
const modules = require('./modules/loader')
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
const shardMarket = require('./model/shardMarket/shardMarket.model')
const createLogger = require('./utils/logger')
@@ -80,16 +80,15 @@ async function start() {
log.warn('mobile-auth-bridge prune failed', { error: err.message })
}
// Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
// change over its lifetime — facets get added, replaced or renamed — so the
// atlas is rebuilt on every boot rather than shipped as a snapshot that would
// silently go stale. Hash-gated, so an unchanged tree costs one read pass and
// no database write.
// Installed modules' onBoot hooks. After ensureSchema() and seedDefaults(), so
// a module's own tables exist; before the listener binds, so a module that must
// warm a cache before serving gets that for free. Each hook is individually
// try/caught inside the loader — a module that throws here loses its `started`
// state and its routes answer 503, and the site still comes up.
//
// Best-effort by contract: no configured path, an unreadable mount or a
// malformed file must never stop the site coming up. A refresh that would
// REMOVE a facet is staged for admin approval instead of being applied.
await shardAtlas.refreshOnBoot()
// The spawn atlas's boot refresh used to be an explicit call here; it is now
// module-uo's onBoot (docs/website/MODULE_API.md §2.5).
await modules.boot()
// Refresh the cliloc table (UO's id → display-string map) from the file the
// operator converted out of their own client. Same contract as the atlas:
@@ -178,6 +177,7 @@ function setupShutdown(server, internalServer) {
announceWorker.stop() // stop the news-announcement dispatcher poller
uoLinkSocket.stop() // close the uo-link WS ingest client
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
await modules.shutdown() // installed modules' onShutdown, bounded, never throwing
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {

View File

@@ -41,34 +41,67 @@ async function query(sql, params) {
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
* Split a schema file into executable statements.
*
* Strips `--` comments (full-line AND trailing) before splitting — so a leading
* comment block doesn't get glued onto the statement that follows it, and a `;`
* inside a trailing comment can't chop a statement in half. Safe because the
* schema never puts `--` inside a string literal, which is a rule module
* fragments inherit (docs/website/MODULE_API.md §2.6).
*/
function statementsOf(sql) {
return sql
.split('\n')
.map((line) => {
const i = line.indexOf('--')
return i === -1 ? line : line.slice(0, i)
})
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
/**
* Create tables if they do not exist. Idempotent. Retries while the DB is still
* coming up (important under docker-compose even with a healthcheck).
*
* Installed modules' schema fragments are replayed immediately after core's, by
* this same function — there is no migration runner here to model a module one
* on, and inventing one for modules alone would leave core and modules on two
* different schema models (MODULE_SYSTEM.md §1.6).
*/
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const conn = await pool.getConnection()
try {
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
// Strip `--` comments (full-line AND trailing) before splitting — so a
// leading comment block doesn't get glued onto the statement that follows
// it, and a `;` inside a trailing comment can't chop a statement in half.
// Safe because the schema never puts `--` inside a string literal.
const statements = sql
.split('\n')
.map((line) => {
const i = line.indexOf('--')
return i === -1 ? line : line.slice(0, i)
})
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0)
for (const statement of statements) {
for (const statement of statementsOf(fs.readFileSync(SCHEMA_PATH, 'utf8'))) {
await conn.query(statement)
}
log.info('schema ensured')
// Module fragments, after core's. Required lazily: the loader requires
// this file for ctx.db, and a top-level require would be a cycle.
// eslint-disable-next-line global-require
const modules = require('../modules/loader')
for (const fragment of modules.schemaFragments()) {
// Per fragment, not per statement: a module whose schema is broken
// must lose its own tables and nothing else, and must not abort the
// retry loop and take the site's boot with it.
try {
for (const statement of statementsOf(fragment.sql)) {
await conn.query(statement)
}
log.info(`schema ensured for module "${fragment.id}"`)
} catch (err) {
modules.markFailed(fragment.id, `schema fragment: ${err.message}`)
log.error(`module "${fragment.id}" schema fragment failed — its routes will answer 503`, {
reason: err.message,
})
}
}
return
} finally {
conn.release()

View File

@@ -81,8 +81,9 @@ function absolutize(url) {
* byte-identical property without a database.
*
* @param {string} html the built index.html
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
* effective brand assets and theme; anything absent falls back to BRAND_* env
* @param {{logo?: string, favicon?: string, theme?: object|null, moduleEntries?: string[]}} [overrides]
* effective brand assets and theme; anything absent falls back to BRAND_* env.
* `moduleEntries` are same-origin URLs of installed modules' prebuilt chunks.
* @returns {string}
*/
function render(html, overrides = {}) {
@@ -102,6 +103,7 @@ function render(html, overrides = {}) {
`<meta name="twitter:description" content="${desc}" />`,
favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
themeStyleTag(overrides.theme),
...moduleScriptTags(overrides.moduleEntries),
]
.filter(Boolean)
.join('\n ')
@@ -123,6 +125,27 @@ function themeStyleTag(theme) {
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
}
// Installed modules' prebuilt client chunks (docs/website/MODULE_API.md §3.1).
//
// `type="module"` with a `src`, never inline: `script-src 'self'` admits a
// same-origin src with no nonce, and an inline tag would be blocked outright —
// which is also why the shared dependencies ride on window.__rg rather than an
// import map, since an import map has to be inline.
//
// `defer` is implicit for a module script, so these evaluate after the SPA's own
// bundle has published window.__rg and before it renders. The path is built by
// the loader from the module id, so nothing user-supplied reaches the attribute;
// it is escaped anyway, because a rule about what CAN appear here should not
// depend on a validator three modules away staying strict.
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9._-]+\.js$/
function moduleScriptTags(entries) {
if (!Array.isArray(entries)) return []
return entries
.filter((src) => typeof src === 'string' && MODULE_ENTRY_PATH.test(src))
.map((src) => `<script type="module" src="${htmlEscape(src)}"></script>`)
}
/**
* Provide the built index.html. Called once at boot by app.js; a separate step
* from get() so the file read stays synchronous and startup still fails loudly
@@ -170,7 +193,18 @@ async function get() {
// mean a failing query per page view.
overrides = {}
}
const html = render(template, overrides)
// The module list is filesystem-derived and synchronous, so unlike the brand
// read above it cannot fail on a DB fault and needs no fallback. Only STARTED
// modules get a script tag: a module whose onBoot failed answers 503 on its
// API, and loading its client half would render pages against a dead backend.
// eslint-disable-next-line global-require
const modules = require('../modules/loader')
const moduleEntries = modules
.list()
.filter((m) => m.state === 'started' && m.entryUrl)
.map((m) => m.entryUrl)
const html = render(template, { ...overrides, moduleEntries })
// An invalidation that landed while this read was in flight means the value
// we just read may already be stale. Serve it, but do not cache it.
if (generation === startedAt) cached = { html, at: Date.now() }

View File

@@ -25,10 +25,11 @@
// with the offending key named, while a bad stored value is dropped entry by
// entry so one hand-edited row does not cost the admin the rest of their nav.
// The four overridable fields. `group` is only meaningful on the grouped admin
// nav, but accepting it everywhere costs nothing — the merge util drops a group
// the base nav does not declare, and the flat navs declare none at all.
const FIELDS = ['label', 'order', 'hidden', 'group']
// The overridable fields on a CODED item. `group` is only meaningful on the
// grouped admin nav and `section` only on the public header, but accepting both
// everywhere costs nothing — the merge util drops a group the base nav does not
// declare, and a section id no `sections` entry declares.
const FIELDS = ['label', 'order', 'hidden', 'group', 'section']
// Bounds. None of these is a security control on its own — the row is written by
// an admin and rendered as text by React — they keep a single settings row from
@@ -38,6 +39,20 @@ const MAX_ENTRIES = 200
const MAX_PATH = 128
const MAX_LABEL = 64
const MAX_GROUP = 64
const MAX_SECTIONS = 12
const MAX_LINKS = 40
// Only the public header supports admin-created dropdown sections and
// admin-authored links (THEMING_AND_NAV.md §7, Phase 10). The admin sidebar has
// its own coded sections and the player portal is three flat rows, so both keep
// the bare items map; `sections`/`links` are dropped for them rather than
// rejected, the same posture as every other unusable field here.
const SECTIONED_KEYS = ['nav_public']
// Generated by the editor, never typed. Constrained so a stored id is safe to
// use as a React key and as a DOM id fragment without further escaping.
const SECTION_ID = /^sec_[a-z0-9]{4,16}$/
const LINK_ID = /^lnk_[a-z0-9]{4,16}$/
// The one item an override may never hide: the nav editor itself. An admin who
// hid it would lose the only screen that can un-hide it, and "type the URL from
@@ -61,6 +76,100 @@ function isNavPath(value) {
return true
}
/**
* Split a stored value into its three parts.
*
* The public header grew dropdown sections in Phase 10, so `nav_public` may be
* a wrapper — `{ items, sections, links }` — while the other two navs stay the
* bare items map phases 6-8 wrote. **A bare map is still read as the items
* map**, which is unambiguous because every item key is a path beginning with
* `/` and so can never be the string `items`.
*
* @param {object} value a parsed, non-array object
* @returns {{items: object, sections: unknown, links: unknown, wrapped: boolean}}
*/
function unwrap(value) {
const wrapped = value.items && typeof value.items === 'object' && !Array.isArray(value.items)
if (!wrapped) return { items: value, sections: undefined, links: undefined, wrapped: false }
return { items: value.items, sections: value.sections, links: value.links, wrapped: true }
}
// A section is a dropdown an admin created: a label and a position, no route.
// It is never itself a link — it only opens — so there is no `to` to validate.
function validateSections(sections, key) {
if (sections === undefined || sections === null) return { ok: true }
if (!Array.isArray(sections)) return { ok: false, message: `${key}.sections must be an array` }
if (sections.length > MAX_SECTIONS) {
return { ok: false, message: `${key} may hold at most ${MAX_SECTIONS} sections` }
}
const seen = new Set()
for (const section of sections) {
if (!section || typeof section !== 'object' || Array.isArray(section)) {
return { ok: false, message: `${key}.sections entries must be objects` }
}
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id)) {
return { ok: false, message: `${key}.sections has an entry with an invalid id` }
}
if (seen.has(section.id)) {
return { ok: false, message: `${key}.sections has a duplicate id '${section.id}'` }
}
seen.add(section.id)
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) {
return { ok: false, message: `${key}.sections['${section.id}'].label must be text of at most ${MAX_LABEL} characters` }
}
if (section.order !== undefined && (typeof section.order !== 'number' || !Number.isFinite(section.order))) {
return { ok: false, message: `${key}.sections['${section.id}'].order must be a number` }
}
}
return { ok: true }
}
// A link is the one thing an admin may ADD to a nav, and the only place a `to`
// is not required to already exist in code. It is kept in its own array rather
// than in `items` on purpose: `items` may only key routes the base array
// declares, so an override structurally cannot invent a route, and everything
// that CAN name an arbitrary path is here where the path rule is applied.
//
// A link carries no `roles` or `feature` of its own. It does not need one: the
// page behind it enforces its own access, so a link to somewhere the viewer
// cannot reach 403s exactly as typing the URL would (§7).
function validateLinks(links, key) {
if (links === undefined || links === null) return { ok: true }
if (!Array.isArray(links)) return { ok: false, message: `${key}.links must be an array` }
if (links.length > MAX_LINKS) {
return { ok: false, message: `${key} may hold at most ${MAX_LINKS} added links` }
}
const seen = new Set()
for (const link of links) {
if (!link || typeof link !== 'object' || Array.isArray(link)) {
return { ok: false, message: `${key}.links entries must be objects` }
}
if (typeof link.id !== 'string' || !LINK_ID.test(link.id)) {
return { ok: false, message: `${key}.links has an entry with an invalid id` }
}
if (seen.has(link.id)) {
return { ok: false, message: `${key}.links has a duplicate id '${link.id}'` }
}
seen.add(link.id)
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) {
return { ok: false, message: `${key}.links['${link.id}'].label must be text of at most ${MAX_LABEL} characters` }
}
// The whole point of the restriction: an added link points somewhere on this
// site. No scheme, no `//host` — the nav is not a place to send visitors off
// to an origin the operator does not control.
if (!isNavPath(link.to)) {
return { ok: false, message: `${key}.links['${link.id}'].to must be a path on this site, such as /wiki/new-player-guide` }
}
if (link.order !== undefined && (typeof link.order !== 'number' || !Number.isFinite(link.order))) {
return { ok: false, message: `${key}.links['${link.id}'].order must be a number` }
}
if (link.section !== undefined && link.section !== null && typeof link.section !== 'string') {
return { ok: false, message: `${key}.links['${link.id}'].section must be a section id` }
}
}
return { ok: true }
}
/**
* Validate a nav-override object for WRITING. Strict: names the offending key.
* @param {unknown} value the parsed object, or null to clear every override
@@ -72,7 +181,16 @@ function validateNavOverrides(value, key = 'nav') {
if (typeof value !== 'object' || Array.isArray(value)) {
return { ok: false, message: `${key} must be a JSON object` }
}
const entries = Object.entries(value)
const { items, sections, links } = unwrap(value)
if (!items || typeof items !== 'object' || Array.isArray(items)) {
return { ok: false, message: `${key}.items must be a JSON object` }
}
const sectionCheck = validateSections(sections, key)
if (!sectionCheck.ok) return sectionCheck
const linkCheck = validateLinks(links, key)
if (!linkCheck.ok) return linkCheck
const entries = Object.entries(items)
if (entries.length > MAX_ENTRIES) {
return { ok: false, message: `${key} may hold at most ${MAX_ENTRIES} entries` }
}
@@ -96,6 +214,9 @@ function validateNavOverrides(value, key = 'nav') {
if (field === 'order' && (typeof fieldValue !== 'number' || !Number.isFinite(fieldValue))) {
return { ok: false, message: `${key}['${to}'].order must be a number` }
}
if (field === 'section' && fieldValue !== null && typeof fieldValue !== 'string') {
return { ok: false, message: `${key}['${to}'].section must be a section id` }
}
// `hidden: false` is not an error — it is simply the default, and the
// editor sends it while a row is being edited. It is dropped below, never
// stored, because hiding is subtractive only (§7): a stored `false` could
@@ -117,15 +238,78 @@ function validateNavOverrides(value, key = 'nav') {
* was customised" (§4.1);
* • reading — a hand-edited entry is dropped and its neighbours kept.
*
* Sections and added links are honored only for the navs that can render them
* (`nav_public`), and a `section` naming no surviving section falls back to the
* top level rather than stranding the item in a dropdown that is not there.
*
* The return shape mirrors the input: a nav with no sections and no added links
* resolves to the bare items map phases 6-8 wrote, so adding this feature
* changed nothing at all for a nav that does not use it.
*
* @param {object|null} value an object, or a parseJsonSetting result
* @param {string} [key] the settings key, so the un-hideable rule can apply
* @returns {object} a new object, `{}` when nothing survives
*/
function resolveNavOverrides(value, key = 'nav') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const { items, sections, links } = unwrap(value)
if (!items || typeof items !== 'object' || Array.isArray(items)) return {}
const sectioned = SECTIONED_KEYS.includes(key)
const cleanSections = sectioned ? resolveSections(sections) : []
const known = new Set(cleanSections.map((s) => s.id))
const cleanLinks = sectioned ? resolveLinks(links, known) : []
const out = resolveItems(items, key, known)
if (cleanSections.length === 0 && cleanLinks.length === 0) return out
// A section with nothing in it renders as an empty dropdown, so an admin who
// emptied one has simply stopped using it — but it is theirs to keep until
// they delete it, and the editor is where that happens. Kept here; the
// renderer drops it (client/src/lib/navOverrides.js pruneNav).
const wrapper = { items: out }
if (cleanSections.length) wrapper.sections = cleanSections
if (cleanLinks.length) wrapper.links = cleanLinks
return wrapper
}
function resolveSections(sections) {
const out = []
const seen = new Set()
if (!Array.isArray(sections)) return out
for (const section of sections.slice(0, MAX_SECTIONS)) {
if (!section || typeof section !== 'object' || Array.isArray(section)) continue
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id) || seen.has(section.id)) continue
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) continue
seen.add(section.id)
const clean = { id: section.id, label: section.label.trim() }
if (typeof section.order === 'number' && Number.isFinite(section.order)) clean.order = section.order
out.push(clean)
}
return out
}
function resolveLinks(links, knownSections) {
const out = []
const seen = new Set()
if (!Array.isArray(links)) return out
for (const link of links.slice(0, MAX_LINKS)) {
if (!link || typeof link !== 'object' || Array.isArray(link)) continue
if (typeof link.id !== 'string' || !LINK_ID.test(link.id) || seen.has(link.id)) continue
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) continue
if (!isNavPath(link.to)) continue
seen.add(link.id)
const clean = { id: link.id, label: link.label.trim(), to: link.to }
if (typeof link.order === 'number' && Number.isFinite(link.order)) clean.order = link.order
if (typeof link.section === 'string' && knownSections.has(link.section)) clean.section = link.section
out.push(clean)
}
return out
}
function resolveItems(items, key, knownSections) {
const out = {}
if (!value || typeof value !== 'object' || Array.isArray(value)) return out
const unhideable = UNHIDEABLE[key] || []
for (const [to, entry] of Object.entries(value)) {
for (const [to, entry] of Object.entries(items)) {
if (!isNavPath(to) || !entry || typeof entry !== 'object' || Array.isArray(entry)) continue
const clean = {}
// A label that is only whitespace is not a label — it would render an
@@ -140,6 +324,10 @@ function resolveNavOverrides(value, key = 'nav') {
if (typeof entry.group === 'string' && entry.group.trim() && entry.group.length <= MAX_GROUP) {
clean.group = entry.group.trim()
}
// Only a section that survived resolution: an item pointing at a deleted or
// malformed one belongs at the top level, visible, rather than inside a
// dropdown that no longer exists.
if (typeof entry.section === 'string' && knownSections.has(entry.section)) clean.section = entry.section
if (Object.keys(clean).length > 0) out[to] = clean
}
return out

View File

@@ -3463,7 +3463,7 @@
"Admin · Settings"
],
"summary": "Update site settings (admin only)",
"description": "Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides carry only label/order/hidden/group; whether a key names a route the nav declares is settled client-side at merge time.",
"description": "Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.",
"responses": {
"200": {
"description": "Updated settings",
@@ -11201,367 +11201,6 @@
]
}
},
"/api/v1/public/atlas/champions": {
"get": {
"tags": [
"Public · Atlas"
],
"summary": "Configured champion altars (the roster, not the live board)",
"description": "Where the altars are and what each one summons — \"there is an Unholy Terror altar in Deceit\". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board (\"it is on level 3 right now\").",
"parameters": [
{
"name": "facet",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Limit to one facet."
}
],
"responses": {
"200": {
"description": "Altars, by facet then name",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AtlasChampion"
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/public/atlas/creatures": {
"get": {
"tags": [
"Public · Atlas"
],
"summary": "Search the bestiary (paginated)",
"description": "Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\\'s share on it. Static content parsed from the shard\\'s ServUO tree — unaffected by the shard being offline.",
"parameters": [
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Substring match on the creature name (max 60 chars)."
},
{
"name": "facet",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Limit to creatures spawning on this facet. Facet names come from the shard's own files; an unknown one returns an empty page."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Page size, 1..100 (default 50)."
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Rows to skip (default 0)."
}
],
"responses": {
"200": {
"description": "A page of creatures plus the unpaginated total",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AtlasCreaturePage"
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "The atlas feature is gated above this caller",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "The atlas feature is disabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/public/atlas/creatures/{slug}": {
"get": {
"tags": [
"Public · Atlas"
],
"summary": "One creature: where it spawns, and what spawns with it",
"description": "The answer the atlas exists to give. `places` is the aggregate — \"lizardman → Shrines, Isamu-Jima, Yew\" — resolved by point-in-rect against the shard\\'s own region rectangles, falling back to the nearest landmark, else \"Wilderness\". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Creature slug, e.g. lizardman."
},
{
"name": "facet",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Restrict places and spawners to one facet."
},
{
"name": "points",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Max spawners to return, 1..1000 (default 200)."
}
],
"responses": {
"200": {
"description": "The creature",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AtlasCreature"
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "No such creature in this atlas (or the feature is disabled)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/public/atlas/landmarks": {
"get": {
"tags": [
"Public · Atlas"
],
"summary": "Points of interest (dungeon levels, town markers)",
"description": "From the shard\\'s Data/Locations files. `group` is the innermost enclosing parent (\"Covetous\"), which is the label worth showing over the individual marker (\"Level 1\").",
"parameters": [
{
"name": "facet",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Limit to one facet."
},
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Substring match on the landmark name or its group."
}
],
"responses": {
"200": {
"description": "Landmarks, by facet then group",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AtlasLandmark"
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/public/atlas/meta": {
"get": {
"tags": [
"Public · Atlas"
],
"summary": "What atlas is loaded: facets, counts, when it was imported",
"description": "Drives the facet filter and the \"parsed from the shard\\'s own files on <date>\" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.",
"responses": {
"200": {
"description": "Atlas metadata",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AtlasMeta"
}
}
}
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/public/atlas/regions": {
"get": {
"tags": [
"Public · Atlas"
],
"summary": "Named regions and their rectangles",
"description": "Flattened out of the shard\\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.",
"parameters": [
{
"name": "facet",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Limit to one facet."
},
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Substring match on the region name."
}
],
"responses": {
"200": {
"description": "Regions, by facet then name",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AtlasRegion"
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/api/v1/public/contact": {
"post": {
"tags": [

View File

@@ -23,11 +23,21 @@ const assert = require('node:assert/strict')
// never blocked by a bad tree), and the admin needs to be told what is wrong
// with their path;
// • a model failure degrades to a 500 rather than a thrown/uncaught error.
const pub = require('../src/router/v1/public/atlas.controller')
// The module's files read core through their `core` shim, which register()
// normally fills. Nothing registers modules in a unit test, so install the
// module's own fake ctx first — before any of its files are required, since the
// controller resolves its logger at require time.
require('../../modules/uo/server/test/_ctx').installFakeCtx()
// SPIKE ARTIFACT (see admin/shardAtlas.controller.js): the public atlas
// controller and its model are module-uo's now. Phase 3 moves this test into the
// module alongside them; until the admin half moves too, one test file has to
// see both sides.
const pub = require('../../modules/uo/server/router/atlas.controller')
const admin = require('../src/router/v1/admin/shardAtlas.controller')
const atlas = require('../src/model/shardAtlas/shardAtlas.model')
const atlas = require('../../modules/uo/server/model/shardAtlas/shardAtlas.model')
const activity = require('../src/model/activity/activity.model')
const visibility = require('../src/utils/shardVisibility')
const visibility = require('../../modules/uo/server/utils/visibility')
const db = require('../src/utils/db')
after(() => db.close())
@@ -36,7 +46,7 @@ after(() => db.close())
// module-internal getConfig, which an exports-level stub would not intercept — it
// would hit the closed DB port and cost a ~10s pool timeout per test before
// falling back to these same defaults.
const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model')
const visibilityModel = require('../../modules/uo/server/model/shardVisibility/shardVisibility.model')
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'

View File

@@ -0,0 +1,256 @@
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const db = require('../src/utils/db')
after(() => db.close())
// ── The loader's failure guarantees ────────────────────────────────────────
//
// docs/website/MODULE_API.md §4.4 promises that a module which fails ANYWHERE in
// its lifecycle fails alone: the site comes up, other modules are unaffected, and
// the failure is recorded rather than thrown. That is the property most worth a
// test, because the failure paths are the ones nobody exercises by hand — every
// manual check runs the happy path.
//
// Each test builds a throwaway modules directory, points MODULES_DIR at it and
// re-requires the loader with a clean cache, so the scan is genuinely redone.
let tmpRoot
function freshLoader(dir) {
process.env.MODULES_DIR = dir
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
return require('../src/modules/loader')
}
function writeModule(id, { manifest = {}, server, schema } = {}) {
const dir = path.join(tmpRoot, id)
fs.mkdirSync(dir, { recursive: true })
const full = {
id,
name: id,
version: '1.0.0',
coreApi: '^1.0.0',
...(server === undefined ? {} : { server: 'index.js' }),
...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }),
...manifest,
}
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(full))
if (server !== undefined) fs.writeFileSync(path.join(dir, 'index.js'), server)
if (schema !== undefined) {
fs.writeFileSync(path.join(dir, 'schema.sql'), schema)
fs.writeFileSync(path.join(dir, 'purge.sql'), '')
}
return dir
}
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
})
test('a missing modules directory is the normal case, not an error', () => {
const loader = freshLoader(path.join(tmpRoot, 'does-not-exist'))
assert.deepEqual(loader.list(), [])
})
test('a module whose entry point throws does not stop the others loading', () => {
writeModule('aaa', { server: 'module.exports = () => {}' })
writeModule('bbb', { server: 'throw new Error("boom")' })
writeModule('ccc', { server: 'module.exports = () => {}' })
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'aaa').state, 'registered')
assert.equal(stateOf(loader, 'ccc').state, 'registered')
const bad = stateOf(loader, 'bbb')
assert.equal(bad.state, 'startup_failed')
assert.match(bad.reason, /boom/)
})
test('a coreApi mismatch is refused before the module is required at all', () => {
// The entry point would throw if it ran; the version gate must run first.
writeModule('old', {
manifest: { coreApi: '^99.0.0' },
server: 'throw new Error("should never be required")',
})
const loader = freshLoader(tmpRoot)
const mod = stateOf(loader, 'old')
assert.equal(mod.state, 'startup_failed')
assert.match(mod.reason, /needs core API \^99\.0\.0/)
})
test('an unknown manifest key is rejected, not ignored', () => {
// A typo'd key must be loud: an operator who believes they configured
// something and silently did not is worse off than one who sees a failure.
writeModule('typo', { manifest: { mount: { public: ['/x'] } } })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'typo').reason, /unknown key "mount"/)
})
test('a module id that does not match its directory is rejected', () => {
writeModule('onedir', { manifest: { id: 'another' } })
const loader = freshLoader(tmpRoot)
// Recorded under the DIRECTORY name — the id it claimed is exactly what is
// not trusted here.
assert.match(stateOf(loader, 'onedir').reason, /does not match directory/)
})
test('two modules cannot claim the same prefix; the first one wins', () => {
writeModule('aaa', {
manifest: { mounts: { public: ['/thing'] } },
server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/thing': ctx.express.Router() } })",
})
writeModule('bbb', {
manifest: { mounts: { public: ['/thing'] } },
server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/thing': ctx.express.Router() } })",
})
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'aaa').state, 'registered')
assert.match(stateOf(loader, 'bbb').reason, /already registered by module "aaa"/)
})
test('a module cannot take a prefix core owns', () => {
writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'greedy').reason, /owned by core/)
})
test('registering a prefix that was never declared is rejected', () => {
// module.json is what the admin panel, the collision check and the reviewer
// all read, so it has to be the truth rather than a hint.
writeModule('sneaky', {
manifest: { mounts: { public: ['/declared'] } },
server: `module.exports = (ctx, api) => api.registerRoutes({
public: { '/declared': ctx.express.Router(), '/undeclared': ctx.express.Router() },
})`,
})
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'sneaky').reason, /registered public\/undeclared without declaring it/)
})
test('declaring a prefix and never registering it is rejected too', () => {
writeModule('forgetful', {
manifest: { mounts: { public: ['/a', '/b'] } },
server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/a': ctx.express.Router() } })",
})
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'forgetful').reason, /declared public\/b but never registered it/)
})
test('a schema fragment declaring a core table is rejected', () => {
writeModule('thief', { schema: 'CREATE TABLE IF NOT EXISTS users (id INT);' })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'thief').reason, /declares core table "users"/)
})
test('a schema fragment table must carry the module id as a prefix', () => {
writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS widgets (id INT);' })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'mine').reason, /not prefixed "mine_"/)
const ok = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
tmpRoot = ok
writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS mine_widgets (id INT);' })
assert.equal(stateOf(freshLoader(ok), 'mine').state, 'registered')
})
test('declaring a schema without a purge is rejected', () => {
const dir = path.join(tmpRoot, 'noway')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, 'module.json'),
JSON.stringify({ id: 'noway', name: 'x', version: '1.0.0', coreApi: '^1.0.0', schema: 'schema.sql' }),
)
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'noway').reason, /declares schema but no purge/)
})
test('an onBoot that throws marks the module failed and never rejects', async () => {
writeModule('boomer', { server: 'module.exports = (ctx, api) => api.onBoot(async () => { throw new Error("late boom") })' })
writeModule('fine', { server: 'module.exports = (ctx, api) => api.onBoot(async () => {})' })
const loader = freshLoader(tmpRoot)
await loader.boot() // must resolve, not reject
assert.equal(stateOf(loader, 'fine').state, 'started')
const bad = stateOf(loader, 'boomer')
assert.equal(bad.state, 'startup_failed')
assert.match(bad.reason, /onBoot: late boom/)
})
test('onShutdown failures and hangs are absorbed', async () => {
writeModule('slow', {
server: 'module.exports = (ctx, api) => { api.onBoot(async () => {}); api.onShutdown(() => new Promise(() => {})) }',
})
writeModule('angry', {
server: 'module.exports = (ctx, api) => { api.onBoot(async () => {}); api.onShutdown(async () => { throw new Error("nope") }) }',
})
const loader = freshLoader(tmpRoot)
await loader.boot()
// `slow` never settles its promise; the loader's own budget has to end it, and
// `angry` throwing must not stop the loop either. Neither may reject.
await loader.shutdown()
})
test('registering the same thing twice is an error, not a silent overwrite', () => {
writeModule('twice', {
manifest: { mounts: { public: ['/x'] } },
server: `module.exports = (ctx, api) => {
api.registerRoutes({ public: { '/x': ctx.express.Router() } })
api.registerRoutes({ public: { '/x': ctx.express.Router() } })
}`,
})
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'twice').reason, /registerRoutes\(\) called twice/)
})
test('ctx exposes exactly the documented surface, and is frozen', () => {
const seen = path.join(tmpRoot, 'probe-out.json')
writeModule('probe', {
server: `const fs = require('fs')
module.exports = (ctx) => {
let mutable = true
try { ctx.db.query = null; mutable = ctx.db.query === null } catch { mutable = false }
fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({
keys: Object.keys(ctx).sort(),
middleware: Object.keys(ctx.middleware).sort(),
mutable,
}))
}`,
})
// list() is what triggers the lazy scan — requiring the loader alone does not
// run it, deliberately, so app.js controls when modules are discovered.
assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered')
const probe = JSON.parse(fs.readFileSync(seen, 'utf8'))
assert.deepEqual(probe.keys, [
'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
'posts', 'push', 'secretBox', 'settings', 'uploads', 'validator',
])
assert.deepEqual(probe.middleware, ['noindex', 'requireAuth', 'requireRole', 'siteMode', 'validate'])
assert.equal(probe.mutable, false, 'ctx members must be frozen')
})
test('the client and server halves agree on MODULE_API_VERSION', () => {
const { MODULE_API_VERSION } = require('../src/modules/version')
const clientSrc = fs.readFileSync(
path.join(__dirname, '..', '..', 'client', 'src', 'modules', 'version.js'),
'utf8',
)
// They version ONE contract; a module checks whichever half it is talking to,
// so a drift between them is a module that passes one gate and fails the other.
assert.match(clientSrc, new RegExp(`'${MODULE_API_VERSION.replace(/\./g, '\\.')}'`))
})

View File

@@ -169,3 +169,121 @@ test('a non-object resolves to {} rather than throwing', () => {
test('NAV_KEYS names the three rows the controller validates', () => {
assert.deepEqual(NAV_KEYS, ['nav_public', 'nav_admin', 'nav_player'])
})
// ── Sections and added links (phase 10) ───────────────────────────────────
//
// The public header may carry admin-created dropdown sections and links the
// admin authored. The invariant that has to survive is structural: `items` may
// only key routes the code declares, so it can never introduce one, while
// `links` is the one place an arbitrary path may be named — and is therefore
// the one place the path rule is applied.
const WRAPPED = {
items: { '/site/champs': { section: 'sec_abcd', order: 0 } },
sections: [{ id: 'sec_abcd', label: 'The World', order: 3 }],
links: [{ id: 'lnk_wxyz', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_abcd', order: 1 }],
}
test('a bare items map is still valid and still stored as-is', () => {
// Phases 6-8 wrote this shape, and a nav that does not use sections keeps it.
assert.equal(validateNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public').ok, true)
assert.deepEqual(resolveNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public'), {
'/site/news': { label: 'N' },
})
})
test('a wrapped value round-trips with its sections and links', () => {
assert.equal(validateNavOverrides(WRAPPED, 'nav_public').ok, true)
assert.deepEqual(resolveNavOverrides(WRAPPED, 'nav_public'), WRAPPED)
})
test('an added link must point at this site', () => {
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', 'wiki/guide', '/a b', '/a"b']) {
const check = validateNavOverrides(
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Bad', to }] },
'nav_public',
)
assert.equal(check.ok, false, `${to} should be refused`)
assert.match(check.message, /must be a path on this site/)
}
})
test('a link to a path that happens to be gated is allowed — the page is the gate', () => {
// An added link carries no roles/feature of its own and does not need one: the
// route behind it enforces its own access, exactly as typing the URL would.
const check = validateNavOverrides(
{ items: {}, links: [{ id: 'lnk_wxyz', label: 'Admin', to: '/admin/users' }] },
'nav_public',
)
assert.equal(check.ok, true)
})
test('section and link ids are constrained, and duplicates refused', () => {
const bad = [
[{ sections: [{ id: 'nope', label: 'X' }] }, /invalid id/],
[{ sections: [{ id: 'sec_AB', label: 'X' }] }, /invalid id/],
[{ sections: [{ id: 'sec_abcd', label: '' }] }, /label must be text/],
[{ sections: [{ id: 'sec_abcd', label: 'A' }, { id: 'sec_abcd', label: 'B' }] }, /duplicate id/],
[{ links: [{ id: 'sec_abcd', label: 'X', to: '/x' }] }, /invalid id/],
[{ links: [{ id: 'lnk_abcd', label: 'A', to: '/a' }, { id: 'lnk_abcd', label: 'B', to: '/b' }] }, /duplicate id/],
]
for (const [extra, pattern] of bad) {
const check = validateNavOverrides({ items: {}, ...extra }, 'nav_public')
assert.equal(check.ok, false, JSON.stringify(extra))
assert.match(check.message, pattern)
}
})
test('sections and links are bounded', () => {
const sections = Array.from({ length: 13 }, (_, i) => ({ id: `sec_a${String(i).padStart(3, '0')}`, label: 'S' }))
assert.match(validateNavOverrides({ items: {}, sections }, 'nav_public').message, /at most 12 sections/)
const links = Array.from({ length: 41 }, (_, i) => ({ id: `lnk_a${String(i).padStart(3, '0')}`, label: 'L', to: '/x' }))
assert.match(validateNavOverrides({ items: {}, links }, 'nav_public').message, /at most 40 added links/)
})
test('sections and links are dropped for the navs that cannot render them', () => {
// The admin sidebar has its own coded sections and the player portal is three
// flat rows; only the public header supports this.
for (const key of ['nav_admin', 'nav_player']) {
const out = resolveNavOverrides(WRAPPED, key)
assert.equal(out.sections, undefined, key)
assert.equal(out.links, undefined, key)
// The item survives, minus the section it can no longer belong to.
assert.deepEqual(out, { '/site/champs': { order: 0 } })
}
})
test('an item or link naming a section that does not exist falls to the top level', () => {
const out = resolveNavOverrides(
{
items: { '/site/champs': { section: 'sec_gone', order: 2 } },
sections: [{ id: 'sec_abcd', label: 'Real' }],
links: [{ id: 'lnk_wxyz', label: 'L', to: '/x', section: 'sec_gone' }],
},
'nav_public',
)
assert.equal(out.items['/site/champs'].section, undefined)
assert.equal(out.links[0].section, undefined)
})
test('an unusable section or link is dropped, its neighbours kept', () => {
const out = resolveNavOverrides(
{
items: {},
sections: [{ id: 'sec_abcd', label: 'Keep' }, { id: 'bad', label: 'Drop' }],
links: [
{ id: 'lnk_aaaa', label: 'Keep', to: '/keep' },
{ id: 'lnk_bbbb', label: 'Drop', to: 'https://evil.example' },
],
},
'nav_public',
)
assert.deepEqual(out.sections.map((s) => s.label), ['Keep'])
assert.deepEqual(out.links.map((l) => l.label), ['Keep'])
})
test('a wrapper that resolves to nothing usable comes back empty', () => {
// The caller deletes the row rather than storing a wrapper that says nothing.
assert.deepEqual(resolveNavOverrides({ items: {}, sections: [], links: [] }, 'nav_public'), {})
assert.deepEqual(resolveNavOverrides({ items: 'nope' }, 'nav_public'), {})
})