Compare commits
1 Commits
feat/modul
...
spike/modu
| Author | SHA1 | Date | |
|---|---|---|---|
| bf470c7658 |
@@ -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>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
100
client/src/modules/registry.js
Normal file
100
client/src/modules/registry.js
Normal 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,
|
||||
}
|
||||
65
client/src/modules/shared.js
Normal file
65
client/src/modules/shared.js
Normal 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),
|
||||
})
|
||||
}
|
||||
8
client/src/modules/version.js
Normal file
8
client/src/modules/version.js
Normal 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
50
modules/uo/SPIKE.md
Normal 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
409
modules/uo/client/dist/entry.js
vendored
Normal 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
1691
modules/uo/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
modules/uo/client/package.json
Normal file
13
modules/uo/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
47
modules/uo/client/src/api.js
Normal file
47
modules/uo/client/src/api.js
Normal 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 }
|
||||
53
modules/uo/client/src/entry.jsx
Normal file
53
modules/uo/client/src/entry.jsx
Normal 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 }],
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
@@ -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',
|
||||
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const reactDom = window.__rg.reactDom
|
||||
export default reactDom
|
||||
export const { createRoot, hydrateRoot } = reactDom
|
||||
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal file
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal 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 }
|
||||
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal file
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal 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
19
modules/uo/client/src/shim/react.js
vendored
Normal 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
|
||||
13
modules/uo/client/src/ui.js
Normal file
13
modules/uo/client/src/ui.js
Normal 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 }
|
||||
43
modules/uo/client/vite.config.js
Normal file
43
modules/uo/client/vite.config.js
Normal 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
14
modules/uo/module.json
Normal 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
88
modules/uo/server/core.js
Normal 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 },
|
||||
}
|
||||
21
modules/uo/server/db/purge.sql
Normal file
21
modules/uo/server/db/purge.sql
Normal 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;
|
||||
166
modules/uo/server/db/schema.sql
Normal file
166
modules/uo/server/db/schema.sql
Normal 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;
|
||||
59
modules/uo/server/index.js
Normal file
59
modules/uo/server/index.js
Normal 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,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
//
|
||||
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal 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 }
|
||||
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal 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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
60
modules/uo/server/test/_ctx.js
Normal file
60
modules/uo/server/test/_ctx.js
Normal 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 }
|
||||
@@ -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
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
//
|
||||
435
modules/uo/server/utils/visibility.js
Normal file
435
modules/uo/server/utils/visibility.js
Normal 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,
|
||||
}
|
||||
@@ -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,84 +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;
|
||||
|
||||
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
||||
-- per module the operator has installed onto the modules volume, keyed by the
|
||||
-- module id from its module.json — the same id that names the directory, the URL
|
||||
-- segment and the client registry key.
|
||||
--
|
||||
-- This table is a RECORD of what happened, never the source of truth for what is
|
||||
-- mounted: the loader scans the filesystem at require time, before the database is
|
||||
-- reachable (MODULE_API.md §4.1), so the URL surface is a property of the volume
|
||||
-- and not of a row here. What the row decides is whether a mounted module answers
|
||||
-- (`disabled` ⇒ its guard 404s, §4.5) and what the admin panel shows after a
|
||||
-- failure.
|
||||
--
|
||||
-- `state` is the §2.4 machine in one column: installed → enabled → started, with
|
||||
-- disabled and startup_failed as the recoverable states. `installed` is the
|
||||
-- transient state between an install writing the row and the restart that starts
|
||||
-- it. On every boot each non-disabled row is reset to `enabled` and re-attempted
|
||||
-- (so a fixed module recovers on restart, with no panel visit needed), then the
|
||||
-- load outcome writes `started` or `startup_failed`. Only `disabled` survives a
|
||||
-- boot untouched — it is the operator's decision, not an outcome.
|
||||
--
|
||||
-- failure_stage/failure_reason are §4.4's recorded reason, one of the seven
|
||||
-- validation steps of §4.3 plus `boot`. Both are cleared by every transition that
|
||||
-- is not a failure, so a stale reason can never be shown against a running module.
|
||||
--
|
||||
-- source/sha256 are install provenance (§2.5): the release the bundle came from and
|
||||
-- the digest that was verified before unpacking. Both NULL for a directory placed
|
||||
-- on the volume by hand, which stays supported.
|
||||
CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
id VARCHAR(32) NOT NULL PRIMARY KEY, -- module.json id; names the directory
|
||||
name VARCHAR(128) NOT NULL, -- human label for the admin Modules screen
|
||||
version VARCHAR(32) NOT NULL, -- module.json version (semver)
|
||||
state ENUM('installed','enabled','disabled','started','startup_failed')
|
||||
NOT NULL DEFAULT 'installed',
|
||||
failure_stage VARCHAR(32) NULL, -- manifest|core_api|mounts|extensions|schema|require|register|boot
|
||||
failure_reason TEXT NULL, -- the recorded reason, shown in the admin panel
|
||||
source VARCHAR(255) NULL, -- release URL the bundle came from
|
||||
sha256 CHAR(64) NULL, -- verified bundle digest
|
||||
installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME NULL, -- last successful start
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) 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.
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for installed_modules — the module system's record of what is installed and
|
||||
// what happened to it on the last boot (db/schema.sql, docs/website/MODULE_SYSTEM.md
|
||||
// §2.4). Rows are keyed by module id. All state rules live in modules.model.js;
|
||||
// this file only moves rows.
|
||||
|
||||
const COLS = `id, name, version, state, failure_stage, failure_reason,
|
||||
source, sha256, installed_at, started_at, updated_at`
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM installed_modules ORDER BY id`)
|
||||
|
||||
const getOne = (id) => query(`SELECT ${COLS} FROM installed_modules WHERE id = ?`, [id])
|
||||
|
||||
// Write (or refresh) the row for an installed module. A re-install or an upgrade
|
||||
// updates the metadata and deliberately leaves `state` alone: upgrading an enabled
|
||||
// module must not silently disable it, and re-installing a disabled one must not
|
||||
// silently switch it back on. A brand-new row lands in `installed`, the transient
|
||||
// state the next restart resolves.
|
||||
const upsert = ({ id, name, version, source, sha256 }) =>
|
||||
query(
|
||||
`INSERT INTO installed_modules (id, name, version, source, sha256, state)
|
||||
VALUES (?, ?, ?, ?, ?, 'installed')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
version = VALUES(version),
|
||||
source = VALUES(source),
|
||||
sha256 = VALUES(sha256)`,
|
||||
[id, name, version, source ?? null, sha256 ?? null],
|
||||
)
|
||||
|
||||
// Move one row to a new state. `failureStage`/`failureReason` are written on every
|
||||
// call — a non-failing transition passes nulls, which is what clears a stale reason
|
||||
// off a module that has since come up. `stampStarted` sets started_at to now.
|
||||
const setState = ({ id, state, failureStage = null, failureReason = null, stampStarted = false }) =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = ?, failure_stage = ?, failure_reason = ?
|
||||
${stampStarted ? ', started_at = CURRENT_TIMESTAMP' : ''}
|
||||
WHERE id = ?`,
|
||||
[state, failureStage, failureReason, id],
|
||||
)
|
||||
|
||||
// Boot reset: every row the operator has not disabled goes back to `enabled` with
|
||||
// no failure recorded, so the load that follows writes this boot's outcome rather
|
||||
// than leaving the last one on display. `disabled` is untouched — it is a decision,
|
||||
// not an outcome.
|
||||
const resetForBoot = () =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = 'enabled', failure_stage = NULL, failure_reason = NULL
|
||||
WHERE state <> 'disabled'`,
|
||||
)
|
||||
|
||||
// Drop the row entirely. Only the explicit purge does this (§2.5); a plain
|
||||
// uninstall disables the module and keeps its row and its data.
|
||||
const remove = (id) => query('DELETE FROM installed_modules WHERE id = ?', [id])
|
||||
|
||||
module.exports = { listAll, getOne, upsert, setState, resetForBoot, remove }
|
||||
@@ -1,183 +0,0 @@
|
||||
// The module state machine (docs/website/MODULE_SYSTEM.md §2.4, MODULE_API.md §4.4).
|
||||
//
|
||||
// installed ──► enabled ──► started
|
||||
// │ │
|
||||
// │ └──► startup_failed ──┐
|
||||
// │ │ (retry)
|
||||
// └──────────────► disabled ◄─────────┘
|
||||
//
|
||||
// One row per installed module, one column holding the state. The rules that make
|
||||
// the machine mean anything live here, not in the SQL:
|
||||
//
|
||||
// - `installed` is transient. An install writes the row; the restart that follows
|
||||
// resolves it to `started` or `startup_failed` (§2.5).
|
||||
// - `disabled` is the only state a boot leaves alone. It is the operator's
|
||||
// decision; every other state is an outcome and is recomputed each boot by
|
||||
// beginBoot(). That is what makes a fixed module recover on restart without
|
||||
// anyone visiting the admin panel.
|
||||
// - A failure is recorded with the stage it happened at, and every non-failing
|
||||
// transition clears it — a running module can never show a stale reason.
|
||||
//
|
||||
// What this table does NOT decide is which routes exist. The loader scans the
|
||||
// filesystem at require time, before the database is reachable (MODULE_API.md §4.1),
|
||||
// so a disabled module is still mounted and simply guarded (§4.5). Keeping the URL
|
||||
// surface a property of the volume is what lets routes.manifest.json be generated
|
||||
// off a dead database.
|
||||
|
||||
const db = require('./modules.db')
|
||||
|
||||
const STATES = ['installed', 'enabled', 'disabled', 'started', 'startup_failed']
|
||||
|
||||
// The stage a failure happened at: MODULE_API.md §4.3's seven validation steps,
|
||||
// plus `boot` for an onBoot hook that threw (§2.5).
|
||||
const FAILURE_STAGES = [
|
||||
'manifest',
|
||||
'core_api',
|
||||
'mounts',
|
||||
'extensions',
|
||||
'schema',
|
||||
'require',
|
||||
'register',
|
||||
'boot',
|
||||
]
|
||||
|
||||
class ModuleStateError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message)
|
||||
this.name = 'ModuleStateError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// Legal moves, keyed by target state. Anything not listed is a bug in the caller
|
||||
// and throws rather than writing a row that misrepresents what happened.
|
||||
const ALLOWED_FROM = {
|
||||
// Enabling is the recovery path as well as the first step: a disabled module the
|
||||
// operator switches back on, and a startup_failed one they retry, both land here.
|
||||
enabled: ['installed', 'enabled', 'disabled', 'startup_failed', 'started'],
|
||||
// The operator may disable a module in any state, including one that is running.
|
||||
disabled: STATES,
|
||||
// Reached from `enabled` on a normal boot, and from `installed` on the first boot
|
||||
// after an install (or for a directory placed on the volume by hand, whose row is
|
||||
// written moments earlier in the same boot).
|
||||
started: ['installed', 'enabled'],
|
||||
// Failure always precedes `started` in the lifecycle; `started` is accepted so a
|
||||
// late failure can still be recorded truthfully rather than dropped.
|
||||
startup_failed: ['installed', 'enabled', 'started'],
|
||||
}
|
||||
|
||||
// row → API shape.
|
||||
function serialize(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
state: row.state,
|
||||
failureStage: row.failure_stage ?? null,
|
||||
failureReason: row.failure_reason ?? null,
|
||||
source: row.source ?? null,
|
||||
sha256: row.sha256 ?? null,
|
||||
installedAt: row.installed_at ?? null,
|
||||
startedAt: row.started_at ?? null,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(serialize)
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const rows = await db.getOne(id)
|
||||
return serialize(rows[0])
|
||||
}
|
||||
|
||||
// Record an install (or a re-install / upgrade). Metadata is refreshed; the state is
|
||||
// left as it is, so upgrading an enabled module does not switch it off and
|
||||
// re-installing a disabled one does not switch it on. A new row lands in `installed`.
|
||||
async function recordInstalled({ id, name, version, source = null, sha256 = null }) {
|
||||
if (!id || !name || !version) {
|
||||
throw new ModuleStateError('invalid_module', 'id, name and version are required')
|
||||
}
|
||||
await db.upsert({ id, name, version, source, sha256 })
|
||||
return get(id)
|
||||
}
|
||||
|
||||
// Start of boot: clear the last boot's outcomes so what is on display after this
|
||||
// boot is what this boot did. Leaves `disabled` rows alone (see the header).
|
||||
// Returns the number of rows reset.
|
||||
async function beginBoot() {
|
||||
const res = await db.resetForBoot()
|
||||
return res?.affectedRows ?? 0
|
||||
}
|
||||
|
||||
// Apply one transition, after checking it is legal for the row's current state.
|
||||
// A row that does not exist is not an error the caller can act on — a module can be
|
||||
// present on the volume with no row at all — so it returns null and writes nothing.
|
||||
async function transition(id, target, { failureStage = null, failureReason = null } = {}) {
|
||||
const current = await get(id)
|
||||
if (!current) return null
|
||||
|
||||
const allowed = ALLOWED_FROM[target]
|
||||
if (!allowed.includes(current.state)) {
|
||||
throw new ModuleStateError(
|
||||
'illegal_transition',
|
||||
`module '${id}': cannot move from '${current.state}' to '${target}'`,
|
||||
)
|
||||
}
|
||||
|
||||
await db.setState({
|
||||
id,
|
||||
state: target,
|
||||
failureStage,
|
||||
failureReason,
|
||||
stampStarted: target === 'started',
|
||||
})
|
||||
return get(id)
|
||||
}
|
||||
|
||||
const enable = (id) => transition(id, 'enabled')
|
||||
const disable = (id) => transition(id, 'disabled')
|
||||
const markStarted = (id) => transition(id, 'started')
|
||||
|
||||
// Record a failure at a named stage. Two deliberate softenings, both because this is
|
||||
// called from the boot path where throwing would turn one module's failure into
|
||||
// everybody's (MODULE_API.md §4.4 — the failing module fails alone):
|
||||
//
|
||||
// - a `disabled` row is a no-op. The operator switched it off; a broken module
|
||||
// they already disabled is not news, and overwriting their decision with an
|
||||
// outcome would silently re-enable it on the next boot.
|
||||
// - an unrecognised stage is recorded as `require` rather than rejected, so a
|
||||
// miscategorised failure still reaches the admin panel with its reason intact.
|
||||
async function markStartupFailed(id, { stage, reason }) {
|
||||
const current = await get(id)
|
||||
if (!current || current.state === 'disabled') return current
|
||||
|
||||
return transition(id, 'startup_failed', {
|
||||
failureStage: FAILURE_STAGES.includes(stage) ? stage : 'require',
|
||||
failureReason: String(reason ?? 'unknown error').slice(0, 4000),
|
||||
})
|
||||
}
|
||||
|
||||
// Purge only (§2.5). A plain uninstall disables the module and keeps its row, so its
|
||||
// data survives and the admin panel can still show what was there.
|
||||
async function remove(id) {
|
||||
await db.remove(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STATES,
|
||||
FAILURE_STAGES,
|
||||
ModuleStateError,
|
||||
list,
|
||||
get,
|
||||
recordInstalled,
|
||||
beginBoot,
|
||||
enable,
|
||||
disable,
|
||||
markStarted,
|
||||
markStartupFailed,
|
||||
remove,
|
||||
}
|
||||
495
server/src/modules/loader.js
Normal file
495
server/src/modules/loader.js
Normal 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,
|
||||
}
|
||||
47
server/src/modules/semver.js
Normal file
47
server/src/modules/semver.js
Normal 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 }
|
||||
14
server/src/modules/version.js
Normal file
14
server/src/modules/version.js
Normal 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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
256
server/test/moduleLoader.test.js
Normal file
256
server/test/moduleLoader.test.js
Normal 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, '\\.')}'`))
|
||||
})
|
||||
@@ -1,295 +0,0 @@
|
||||
// Point the DB pool at a dead port before it's built; every modules.db method is
|
||||
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const pool = require('../src/utils/db')
|
||||
after(() => pool.close())
|
||||
|
||||
// Unit-test the module state machine (docs/website/MODULE_SYSTEM.md §2.4) against an
|
||||
// in-memory fake by monkeypatching modules.db (no DB). What is locked here is
|
||||
// everything the boot path and the admin panel will lean on in later phases:
|
||||
// - `installed` is transient and a re-install never overwrites the operator's
|
||||
// enable/disable decision;
|
||||
// - beginBoot() recomputes outcomes and leaves `disabled` alone — the property that
|
||||
// makes a fixed module recover on a restart with no panel visit;
|
||||
// - a failure carries its stage and reason, and every non-failing transition clears
|
||||
// them, so a running module can never display a stale reason;
|
||||
// - a disabled module's failure is a no-op, because the boot path must never turn
|
||||
// one module's failure into a re-enable of a module the operator switched off;
|
||||
// - an illegal move throws instead of writing a row that misrepresents the state.
|
||||
const modulesDb = require('../src/model/modules/modules.db')
|
||||
const modules = require('../src/model/modules/modules.model')
|
||||
|
||||
let rows // id → row, snake_case exactly as modules.db returns it
|
||||
|
||||
const saved = { ...modulesDb }
|
||||
|
||||
function reset() {
|
||||
rows = new Map()
|
||||
}
|
||||
|
||||
modulesDb.listAll = async () => [...rows.values()].sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
modulesDb.getOne = async (id) => (rows.has(id) ? [rows.get(id)] : [])
|
||||
|
||||
modulesDb.upsert = async ({ id, name, version, source, sha256 }) => {
|
||||
const existing = rows.get(id)
|
||||
if (existing) {
|
||||
Object.assign(existing, { name, version, source: source ?? null, sha256: sha256 ?? null })
|
||||
return { affectedRows: 1 }
|
||||
}
|
||||
rows.set(id, {
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
state: 'installed',
|
||||
failure_stage: null,
|
||||
failure_reason: null,
|
||||
source: source ?? null,
|
||||
sha256: sha256 ?? null,
|
||||
installed_at: '2026-08-10T00:00:00Z',
|
||||
started_at: null,
|
||||
updated_at: '2026-08-10T00:00:00Z',
|
||||
})
|
||||
return { affectedRows: 1 }
|
||||
}
|
||||
|
||||
modulesDb.setState = async ({ id, state, failureStage, failureReason, stampStarted }) => {
|
||||
const row = rows.get(id)
|
||||
if (!row) return { affectedRows: 0 }
|
||||
row.state = state
|
||||
row.failure_stage = failureStage ?? null
|
||||
row.failure_reason = failureReason ?? null
|
||||
if (stampStarted) row.started_at = '2026-08-10T12:00:00Z'
|
||||
return { affectedRows: 1 }
|
||||
}
|
||||
|
||||
modulesDb.resetForBoot = async () => {
|
||||
let n = 0
|
||||
for (const row of rows.values()) {
|
||||
if (row.state === 'disabled') continue
|
||||
row.state = 'enabled'
|
||||
row.failure_stage = null
|
||||
row.failure_reason = null
|
||||
n += 1
|
||||
}
|
||||
return { affectedRows: n }
|
||||
}
|
||||
|
||||
modulesDb.remove = async (id) => ({ affectedRows: rows.delete(id) ? 1 : 0 })
|
||||
|
||||
after(() => Object.assign(modulesDb, saved))
|
||||
|
||||
beforeEach(reset)
|
||||
|
||||
// Install one module and put it in a given state, bypassing the machine so a test
|
||||
// can start from any state without asserting its way there.
|
||||
async function seed(id, state = 'installed', extra = {}) {
|
||||
await modules.recordInstalled({ id, name: `Module ${id}`, version: '1.0.0', ...extra })
|
||||
rows.get(id).state = state
|
||||
return modules.get(id)
|
||||
}
|
||||
|
||||
// ── recordInstalled ───────────────────────────────────────────────────
|
||||
|
||||
test('a new install lands in the transient installed state', async () => {
|
||||
const mod = await modules.recordInstalled({
|
||||
id: 'uo',
|
||||
name: 'Ultima Online',
|
||||
version: '1.2.0',
|
||||
source: 'https://gitea.example/releases/module-uo-1.2.0.tar.gz',
|
||||
sha256: 'a'.repeat(64),
|
||||
})
|
||||
assert.equal(mod.state, 'installed')
|
||||
assert.equal(mod.version, '1.2.0')
|
||||
assert.equal(mod.sha256, 'a'.repeat(64))
|
||||
assert.equal(mod.startedAt, null)
|
||||
assert.equal(mod.failureReason, null)
|
||||
})
|
||||
|
||||
test('a hand-placed module records with no provenance', async () => {
|
||||
const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.0.0' })
|
||||
assert.equal(mod.source, null)
|
||||
assert.equal(mod.sha256, null)
|
||||
})
|
||||
|
||||
test('recordInstalled refuses a manifest missing id, name or version', async () => {
|
||||
await assert.rejects(
|
||||
() => modules.recordInstalled({ id: 'uo', version: '1.0.0' }),
|
||||
(err) => err.code === 'invalid_module',
|
||||
)
|
||||
})
|
||||
|
||||
test('an upgrade refreshes metadata and leaves the operator decision alone', async () => {
|
||||
await seed('uo', 'disabled')
|
||||
const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '2.0.0' })
|
||||
assert.equal(mod.version, '2.0.0')
|
||||
assert.equal(mod.name, 'Ultima Online')
|
||||
assert.equal(mod.state, 'disabled', 're-installing must not silently re-enable')
|
||||
})
|
||||
|
||||
test('an upgrade of a started module does not switch it off', async () => {
|
||||
await seed('uo', 'started')
|
||||
const mod = await modules.recordInstalled({ id: 'uo', name: 'Module uo', version: '1.1.0' })
|
||||
assert.equal(mod.state, 'started')
|
||||
})
|
||||
|
||||
// ── the machine ───────────────────────────────────────────────────────
|
||||
|
||||
test('installed → enabled → started, stamping the start', async () => {
|
||||
await seed('uo')
|
||||
assert.equal((await modules.enable('uo')).state, 'enabled')
|
||||
const started = await modules.markStarted('uo')
|
||||
assert.equal(started.state, 'started')
|
||||
assert.ok(started.startedAt, 'a successful start is stamped')
|
||||
})
|
||||
|
||||
test('a first boot may start a module straight from installed', async () => {
|
||||
await seed('uo')
|
||||
assert.equal((await modules.markStarted('uo')).state, 'started')
|
||||
})
|
||||
|
||||
test('a disabled module can be re-enabled', async () => {
|
||||
await seed('uo', 'disabled')
|
||||
assert.equal((await modules.enable('uo')).state, 'enabled')
|
||||
})
|
||||
|
||||
test('a failed module is retried by enabling it, which clears the reason', async () => {
|
||||
await seed('uo', 'enabled')
|
||||
await modules.markStartupFailed('uo', { stage: 'boot', reason: 'atlas refresh threw' })
|
||||
const retried = await modules.enable('uo')
|
||||
assert.equal(retried.state, 'enabled')
|
||||
assert.equal(retried.failureStage, null)
|
||||
assert.equal(retried.failureReason, null)
|
||||
})
|
||||
|
||||
test('a running module can be disabled', async () => {
|
||||
await seed('uo', 'started')
|
||||
assert.equal((await modules.disable('uo')).state, 'disabled')
|
||||
})
|
||||
|
||||
test('a disabled module is never started — that would be a core bug, so it throws', async () => {
|
||||
await seed('uo', 'disabled')
|
||||
await assert.rejects(
|
||||
() => modules.markStarted('uo'),
|
||||
(err) => err.name === 'ModuleStateError' && err.code === 'illegal_transition',
|
||||
)
|
||||
assert.equal((await modules.get('uo')).state, 'disabled')
|
||||
})
|
||||
|
||||
test('a transition on a module with no row writes nothing and returns null', async () => {
|
||||
assert.equal(await modules.enable('ghost'), null)
|
||||
assert.equal(await modules.markStarted('ghost'), null)
|
||||
assert.equal(rows.size, 0)
|
||||
})
|
||||
|
||||
// ── failures ──────────────────────────────────────────────────────────
|
||||
|
||||
test('a failure records its stage and reason', async () => {
|
||||
await seed('uo', 'enabled')
|
||||
const failed = await modules.markStartupFailed('uo', {
|
||||
stage: 'core_api',
|
||||
reason: "module 'uo' needs coreApi ^2.0.0, core provides 1.0.0",
|
||||
})
|
||||
assert.equal(failed.state, 'startup_failed')
|
||||
assert.equal(failed.failureStage, 'core_api')
|
||||
assert.match(failed.failureReason, /coreApi/)
|
||||
})
|
||||
|
||||
test('an unrecognised stage is still recorded, as require', async () => {
|
||||
await seed('uo', 'enabled')
|
||||
const failed = await modules.markStartupFailed('uo', { stage: 'nonsense', reason: 'boom' })
|
||||
assert.equal(failed.failureStage, 'require')
|
||||
assert.equal(failed.failureReason, 'boom')
|
||||
})
|
||||
|
||||
test('a missing reason still produces a displayable one', async () => {
|
||||
await seed('uo', 'enabled')
|
||||
const failed = await modules.markStartupFailed('uo', { stage: 'register' })
|
||||
assert.equal(failed.failureReason, 'unknown error')
|
||||
})
|
||||
|
||||
test('a runaway reason is truncated rather than refused', async () => {
|
||||
await seed('uo', 'enabled')
|
||||
const failed = await modules.markStartupFailed('uo', { stage: 'boot', reason: 'x'.repeat(9000) })
|
||||
assert.equal(failed.failureReason.length, 4000)
|
||||
})
|
||||
|
||||
test("a disabled module's failure is a no-op, not a re-enable", async () => {
|
||||
await seed('uo', 'disabled')
|
||||
const unchanged = await modules.markStartupFailed('uo', { stage: 'require', reason: 'broken' })
|
||||
assert.equal(unchanged.state, 'disabled')
|
||||
assert.equal(unchanged.failureReason, null)
|
||||
})
|
||||
|
||||
test('starting successfully clears the previous failure', async () => {
|
||||
await seed('uo', 'enabled')
|
||||
await modules.markStartupFailed('uo', { stage: 'schema', reason: 'bad fragment' })
|
||||
await modules.enable('uo')
|
||||
const started = await modules.markStarted('uo')
|
||||
assert.equal(started.failureStage, null)
|
||||
assert.equal(started.failureReason, null)
|
||||
})
|
||||
|
||||
// ── boot ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('beginBoot recomputes outcomes and leaves disabled alone', async () => {
|
||||
await seed('a', 'started')
|
||||
await seed('b', 'startup_failed')
|
||||
await seed('c', 'disabled')
|
||||
await seed('d', 'installed')
|
||||
rows.get('b').failure_reason = 'last boot blew up'
|
||||
rows.get('b').failure_stage = 'boot'
|
||||
|
||||
assert.equal(await modules.beginBoot(), 3)
|
||||
|
||||
const byId = Object.fromEntries((await modules.list()).map((m) => [m.id, m]))
|
||||
assert.equal(byId.a.state, 'enabled')
|
||||
assert.equal(byId.b.state, 'enabled', 'a failed module is retried on the next boot')
|
||||
assert.equal(byId.b.failureReason, null, 'and last boot’s reason is cleared')
|
||||
assert.equal(byId.c.state, 'disabled', 'the operator decision survives a boot')
|
||||
assert.equal(byId.d.state, 'enabled')
|
||||
})
|
||||
|
||||
test('beginBoot keeps the start stamp of a module that was running', async () => {
|
||||
await seed('uo', 'enabled')
|
||||
await modules.markStarted('uo')
|
||||
await modules.beginBoot()
|
||||
assert.ok((await modules.get('uo')).startedAt, 'started_at is the last successful start')
|
||||
})
|
||||
|
||||
// ── list / purge ──────────────────────────────────────────────────────
|
||||
|
||||
test('list returns every module, id-ordered and serialized', async () => {
|
||||
await seed('zzz')
|
||||
await seed('aaa')
|
||||
const all = await modules.list()
|
||||
assert.deepEqual(
|
||||
all.map((m) => m.id),
|
||||
['aaa', 'zzz'],
|
||||
)
|
||||
assert.deepEqual(Object.keys(all[0]).sort(), [
|
||||
'failureReason',
|
||||
'failureStage',
|
||||
'id',
|
||||
'installedAt',
|
||||
'name',
|
||||
'sha256',
|
||||
'source',
|
||||
'startedAt',
|
||||
'state',
|
||||
'updatedAt',
|
||||
'version',
|
||||
])
|
||||
})
|
||||
|
||||
test('purge drops the row; uninstall is a disable and keeps it', async () => {
|
||||
await seed('uo', 'started')
|
||||
await modules.disable('uo')
|
||||
assert.ok(await modules.get('uo'), 'uninstall keeps the row and its data')
|
||||
await modules.remove('uo')
|
||||
assert.equal(await modules.get('uo'), null)
|
||||
})
|
||||
Reference in New Issue
Block a user