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>
This commit is contained in:
2026-08-10 05:29:35 -05:00
parent f1dda8fe66
commit bf470c7658
55 changed files with 4638 additions and 601 deletions

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

@@ -7,6 +7,7 @@ import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.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
@@ -33,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' },
@@ -61,10 +61,26 @@ export default function SiteHeader() {
// 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 tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public))
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
}, [settings.nav_public, shardFeatures])
}, [base, settings.nav_public, shardFeatures])
// Where the auth entry points: staff → admin, player → portal, else sign in.
let account

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'

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

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

@@ -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, '\\.')}'`))
})