Compare commits
12 Commits
spike/modu
...
85f563fc16
| Author | SHA1 | Date | |
|---|---|---|---|
| 85f563fc16 | |||
| 32ed8e4411 | |||
| 21196466ed | |||
| 39eaae90a8 | |||
| 97f19b4221 | |||
| 6195c76d61 | |||
| bd749d4f1f | |||
| 2892d01b24 | |||
| 7780fb033b | |||
| ec1ca7e794 | |||
| dcf6ef1886 | |||
| 3add0063bf |
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -24,6 +23,8 @@ 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'
|
||||
@@ -107,19 +108,13 @@ 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 />} />
|
||||
@@ -210,17 +205,6 @@ 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,16 +42,6 @@ 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,7 +7,6 @@ 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
|
||||
@@ -34,6 +33,7 @@ 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,26 +61,10 @@ 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(base, parseJsonSetting(settings.nav_public))
|
||||
const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public))
|
||||
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
}, [base, settings.nav_public, shardFeatures])
|
||||
}, [settings.nav_public, shardFeatures])
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
let account
|
||||
|
||||
@@ -2,37 +2,12 @@ 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'
|
||||
|
||||
// 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()
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// ── 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,
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// ── 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),
|
||||
})
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// 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'
|
||||
@@ -177,14 +177,15 @@ const delStyle = {
|
||||
}
|
||||
|
||||
// ── Announcement status panel ────────────────────────────────────────────────
|
||||
// Shows the town-crier + Discord delivery state for a published news post and
|
||||
// offers a per-leg retry (useful after fixing the sidecar / news channel without
|
||||
// re-publishing). Only rendered for news posts in edit mode; renders nothing
|
||||
// until the post has actually been announced (no job row yet → nothing to show).
|
||||
const LEG_META = {
|
||||
towncrier: { label: 'In-game town crier' },
|
||||
discord: { label: 'Discord #news' },
|
||||
}
|
||||
// Shows each delivery leg's state for a published news post and offers a per-leg
|
||||
// retry (useful after fixing the sidecar / news channel without re-publishing).
|
||||
// Only rendered for news posts in edit mode; renders nothing until the post has
|
||||
// actually been announced (no job row yet → nothing to show).
|
||||
//
|
||||
// The legs and their labels come from the JOB, not from a constant here: which
|
||||
// legs exist is decided by what the server has registered, so an installed module
|
||||
// brings its own leg and this panel renders it with no client change
|
||||
// (docs/website/MODULE_SYSTEM.md §1.8).
|
||||
const STATUS_STYLE = {
|
||||
done: { color: '#7bbf8f', label: 'delivered' },
|
||||
pending: { color: '#d9b84a', label: 'pending' },
|
||||
@@ -227,14 +228,12 @@ function AnnouncePanel({ postId }) {
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||
{['towncrier', 'discord'].map((leg) => {
|
||||
const status = job[`${leg}_status`]
|
||||
const err = job[`${leg}_last_error`]
|
||||
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
|
||||
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
||||
return (
|
||||
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
|
||||
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
|
||||
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
||||
{status !== 'done' && (
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.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 { api } from '../../api/client.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -49,7 +52,7 @@ function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.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 { api } from '../../api/client.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
@@ -135,7 +138,7 @@ export default function AtlasCreature() {
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
@@ -172,7 +175,7 @@ export default function AtlasCreature() {
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
|
||||
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
@@ -1,50 +0,0 @@
|
||||
# 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
409
modules/uo/client/dist/entry.js
vendored
@@ -1,409 +0,0 @@
|
||||
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
1691
modules/uo/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// 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 }
|
||||
@@ -1,53 +0,0 @@
|
||||
// ── 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,3 +0,0 @@
|
||||
const reactDom = window.__rg.reactDom
|
||||
export default reactDom
|
||||
export const { createRoot, hydrateRoot } = reactDom
|
||||
@@ -1,8 +0,0 @@
|
||||
// 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
10
modules/uo/client/src/shim/react-router-dom.js
vendored
@@ -1,10 +0,0 @@
|
||||
// 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
19
modules/uo/client/src/shim/react.js
vendored
@@ -1,19 +0,0 @@
|
||||
// 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
|
||||
@@ -1,13 +0,0 @@
|
||||
// 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 }
|
||||
@@ -1,43 +0,0 @@
|
||||
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 },
|
||||
},
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// ── 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 },
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
-- ── 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;
|
||||
@@ -1,166 +0,0 @@
|
||||
-- ── 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;
|
||||
@@ -1,59 +0,0 @@
|
||||
// ── 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,42 +0,0 @@
|
||||
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 }
|
||||
@@ -1,37 +0,0 @@
|
||||
// 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 }
|
||||
@@ -1,37 +0,0 @@
|
||||
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 }
|
||||
@@ -1,44 +0,0 @@
|
||||
// ── 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 }
|
||||
@@ -1,60 +0,0 @@
|
||||
// ── 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 }
|
||||
@@ -1,435 +0,0 @@
|
||||
// ── 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,
|
||||
}
|
||||
@@ -1106,33 +1106,192 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||
|
||||
-- Announcement pipeline. One row per publish event of a news post; the table
|
||||
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js
|
||||
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
|
||||
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
|
||||
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
|
||||
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
|
||||
-- own attempt count, last error, and next-due time for exponential backoff.
|
||||
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
|
||||
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
|
||||
-- — sweeps it for due legs). `status` is a derived rollup of the legs (see
|
||||
-- announceJobs.logic.js): done when every leg is done, failed when every leg is
|
||||
-- exhausted, partial in between. post_id is INT (matches posts.id) and cascades
|
||||
-- so deleting a post reaps its jobs. posts.announce_job_id points back at the
|
||||
-- latest row for admin lookups.
|
||||
CREATE TABLE IF NOT EXISTS announce_jobs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',
|
||||
|
||||
towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
towncrier_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
towncrier_last_error TEXT NULL,
|
||||
towncrier_next_attempt_at DATETIME NULL,
|
||||
|
||||
discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
discord_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
discord_last_error TEXT NULL,
|
||||
discord_next_attempt_at DATETIME NULL,
|
||||
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
|
||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per delivery leg per job. INDEPENDENT by design: a Discord outage never
|
||||
-- blocks or retries another leg, and each leg tracks its own attempt count, last
|
||||
-- error and next-due time for exponential backoff.
|
||||
--
|
||||
-- This is a child table rather than a pair of leg-prefixed column groups on
|
||||
-- announce_jobs because the leg set is DATA now, not schema: core registers
|
||||
-- `discord`, module-uo registers `towncrier`, and a module for another game
|
||||
-- registers its own — through modules/registries.js's registerAnnounceLeg
|
||||
-- (MODULE_SYSTEM.md §1.8). A module cannot ALTER a core table, so a leg that
|
||||
-- needed its own columns could never come from a module at all. `leg` is a plain
|
||||
-- VARCHAR and not an ENUM for the same reason.
|
||||
CREATE TABLE IF NOT EXISTS announce_job_legs (
|
||||
job_id INT NOT NULL,
|
||||
leg VARCHAR(64) NOT NULL,
|
||||
status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
next_attempt_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (job_id, leg),
|
||||
CONSTRAINT fk_announce_job_legs_job FOREIGN KEY (job_id) REFERENCES announce_jobs(id) ON DELETE CASCADE,
|
||||
INDEX idx_announce_leg_due (status, next_attempt_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Carry the two hardcoded leg column groups over to the child table, once. Guarded
|
||||
-- on the OLD columns still existing (via information_schema, since a plain SELECT
|
||||
-- of a dropped column is a parse error, not a runtime one) and on there being no
|
||||
-- row already, so replaying this file on every boot is a no-op after the first.
|
||||
-- Deleting this block once every deployment has booted it is safe.
|
||||
SET @has_legacy_legs := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'announce_jobs'
|
||||
AND COLUMN_NAME = 'towncrier_status'
|
||||
);
|
||||
SET @sql := IF(@has_legacy_legs > 0,
|
||||
'INSERT IGNORE INTO announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)
|
||||
SELECT id, ''towncrier'', towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at FROM announce_jobs
|
||||
UNION ALL
|
||||
SELECT id, ''discord'', discord_status, discord_attempts, discord_last_error, discord_next_attempt_at FROM announce_jobs',
|
||||
'DO 0');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- MariaDB's IF EXISTS makes this idempotent, so it replays cleanly like the rest
|
||||
-- of the file. It is the one DROP in core's schema, and it is deliberate: leaving
|
||||
-- the columns would leave `towncrier` in a core file, which Phase 3's acceptance
|
||||
-- grep forbids (MODULE_SYSTEM.md §2.7).
|
||||
ALTER TABLE announce_jobs
|
||||
DROP COLUMN IF EXISTS towncrier_status,
|
||||
DROP COLUMN IF EXISTS towncrier_attempts,
|
||||
DROP COLUMN IF EXISTS towncrier_last_error,
|
||||
DROP COLUMN IF EXISTS towncrier_next_attempt_at,
|
||||
DROP COLUMN IF EXISTS discord_status,
|
||||
DROP COLUMN IF EXISTS discord_attempts,
|
||||
DROP COLUMN IF EXISTS discord_last_error,
|
||||
DROP COLUMN IF EXISTS discord_next_attempt_at,
|
||||
DROP INDEX IF EXISTS idx_announce_due,
|
||||
DROP INDEX IF EXISTS idx_announce_due_discord;
|
||||
|
||||
-- ── 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
|
||||
@@ -1168,6 +1327,84 @@ 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.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||
"test": "node --test"
|
||||
"test": "node --test --require ./test/_setup.js"
|
||||
},
|
||||
"keywords": [
|
||||
"express",
|
||||
|
||||
@@ -1062,7 +1062,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/shard/link/:account",
|
||||
"handlers": 5,
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
|
||||
@@ -64,9 +64,16 @@ const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
|
||||
*
|
||||
* Express keeps no copy of the mount string, only the compiled regexp. For a
|
||||
* literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
|
||||
* contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both
|
||||
* gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is
|
||||
* express's marker for a router mounted at the root, which contributes nothing.
|
||||
* contributes one group per entry in `layer.keys`, and the separator before the
|
||||
* parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
|
||||
* `^(?:\/([^/]+?))\/?(?=\/|$)`. Unwinding both gets us back to `/api/v1` and
|
||||
* `/:id` respectively. `fast_slash` is express's marker for a router mounted at
|
||||
* the root, which contributes nothing.
|
||||
*
|
||||
* The parameterised branch went unexercised until the `admin.users.detail`
|
||||
* extension slot mounted a router at `/:id` (MODULE_SYSTEM.md §1.9), and it was
|
||||
* wrong: it expected the group as `(?:([^\/]+?))`, with the slash outside and the
|
||||
* class escaped. It threw rather than guessing, which is exactly what it is for.
|
||||
*/
|
||||
function mountPath(layer) {
|
||||
const re = layer.regexp
|
||||
@@ -79,9 +86,11 @@ function mountPath(layer) {
|
||||
|
||||
const keys = layer.keys || []
|
||||
let i = 0
|
||||
src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => {
|
||||
// `\/` optional and the `/` in the class optionally escaped, so this survives a
|
||||
// path-to-regexp that emits either shape.
|
||||
src = src.replace(/\((?:\?:)?(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_m, slash) => {
|
||||
const key = keys[i++]
|
||||
return key ? `:${key.name}` : ':param'
|
||||
return `${slash ? '/' : ''}:${key ? key.name : 'param'}`
|
||||
})
|
||||
|
||||
// Whatever is left should be a literal path with regexp-escaped separators.
|
||||
|
||||
@@ -10,6 +10,8 @@ require('dotenv').config()
|
||||
const swaggerUi = require('swagger-ui-express')
|
||||
|
||||
const apiRouter = require('./router/api.router')
|
||||
const modules = require('./modules/loader')
|
||||
const registries = require('./modules/registries')
|
||||
const wellKnown = require('./router/wellKnown.controller')
|
||||
const cspReport = require('./router/cspReport.controller')
|
||||
const brand = require('./config/brand')
|
||||
@@ -19,7 +21,6 @@ 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')
|
||||
@@ -109,34 +110,6 @@ 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/).
|
||||
@@ -183,6 +156,34 @@ app.get(
|
||||
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
|
||||
|
||||
app.use('/api', apiRouter)
|
||||
|
||||
// ── Installed modules ─────────────────────────────────────────────────
|
||||
// Discover, validate and mount whatever is on the modules volume
|
||||
// (docs/website/MODULE_API.md Part 4). One explicit call, here and nowhere else:
|
||||
// the loader has no lazy self-scan, so there is exactly one place that decides
|
||||
// when modules are discovered, and reading the module list before this line is
|
||||
// an error rather than a silent empty answer (§7.6).
|
||||
//
|
||||
// Position is load-bearing, in both directions. It is AFTER `/api` is mounted,
|
||||
// so every core prefix is already on the tier routers when the collision check
|
||||
// asks them what core owns — and so first-match-wins means a module physically
|
||||
// cannot shadow a core route. It is BEFORE the `/api` 404 below, so a module
|
||||
// route reaches its handler instead of the catch-all.
|
||||
//
|
||||
// The three requires resolve from cache to the very routers v1.router.js
|
||||
// mounted; this is a reference to them, not a second copy.
|
||||
//
|
||||
// registerCore() first, and for the same reason the loader runs after `/api`: a
|
||||
// module's collision checks are asked against what is ALREADY registered, so
|
||||
// core's streams, its announce leg and its extension-slot fill have to be there
|
||||
// before the first module registers anything (MODULE_SYSTEM.md §1.8).
|
||||
registries.registerCore()
|
||||
modules.load({
|
||||
public: require('./router/v1/public'),
|
||||
admin: require('./router/v1/admin'),
|
||||
player: require('./router/v1/player'),
|
||||
})
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||
|
||||
// ── /.well-known ──────────────────────────────────────────────────────
|
||||
|
||||
26
server/src/config/coreStreams.js
Normal file
26
server/src/config/coreStreams.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── Core's own push-notification streams ───────────────────────────────────
|
||||
//
|
||||
// What is left of config/notificationStreams.js once the shard-derived catalog
|
||||
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
|
||||
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
|
||||
// produced by the website's own posts path, not by any game feed.
|
||||
//
|
||||
// Registered through modules/registries.js like any module's, and read back
|
||||
// through it — nothing imports this file to get "the catalog", because the
|
||||
// catalog is core's plus every module's.
|
||||
//
|
||||
// The payload that ever leaves the server is a CONTENT-FREE tickle
|
||||
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
|
||||
// over the authenticated API (docs/android/PLAN.md §11).
|
||||
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
@@ -1,7 +1,14 @@
|
||||
// ── Push-notification stream catalog + event → stream mapping ───────────────
|
||||
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||
//
|
||||
// The single source of truth for which streams a user can subscribe to, and how
|
||||
// a shard event maps onto them. Two families:
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
|
||||
// config/notificationStreams.js as one of the three genuinely entangled files:
|
||||
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
|
||||
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
|
||||
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
|
||||
// moves to module-uo whole in Phase 3. Nothing in core imports it except
|
||||
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
|
||||
//
|
||||
// Two families:
|
||||
// • public / opt-in — no linked game account required; delivered to every
|
||||
// subscriber. Drawn ONLY from the SSE public allowlist
|
||||
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
||||
@@ -17,17 +24,7 @@
|
||||
|
||||
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
||||
|
||||
// The subscribable catalog. `news.post` is produced by the website's own posts
|
||||
// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
|
||||
// case; every other stream is shard-derived below.
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'server.status',
|
||||
label: 'Server up / down',
|
||||
@@ -79,8 +76,10 @@ const STREAMS = [
|
||||
},
|
||||
]
|
||||
|
||||
const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
|
||||
const isValidStream = (id) => STREAM_IDS.has(id)
|
||||
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
|
||||
// Derived from this file's own catalog rather than read back out of the registry:
|
||||
// the filter is about THESE streams, and a module must not be able to weaken it
|
||||
// by registering something that happens to share an id.
|
||||
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// Per-process transition state so full-state upserts (champ.update / city.update
|
||||
@@ -162,7 +161,12 @@ function mapShardEvent(event, tracker = defaultTracker) {
|
||||
// they are exempt from the public allowlist (that is the whole point of the
|
||||
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
||||
// even if a future mapping case is added carelessly.
|
||||
//
|
||||
// This filter, the kinds it reads and the streams it protects now all live in
|
||||
// one file and move together — the reason PR 4 dropped the contract's
|
||||
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
|
||||
// module (MODULE_API.md §2.4).
|
||||
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||
}
|
||||
|
||||
module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
@@ -1,26 +1,68 @@
|
||||
// ── Announcement pipeline: SQL ─────────────────────────────────────────────
|
||||
//
|
||||
// Two tables since PR 4 (MODULE_SYSTEM.md §1.8): `announce_jobs` is one row per
|
||||
// publish event, `announce_job_legs` one row per delivery leg of that job. The
|
||||
// leg set is registered rather than fixed, so a leg is a stored VALUE now instead
|
||||
// of a group of leg-prefixed columns — which is what lets a module bring its own
|
||||
// leg without altering a core table.
|
||||
//
|
||||
// Every read returns the job with a `legs` array attached, so a caller never has
|
||||
// to remember to fetch the second table.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, post_id, status, ' +
|
||||
'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
|
||||
'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
|
||||
'created_at, updated_at'
|
||||
const COLS = 'id, post_id, status, created_at, updated_at'
|
||||
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
||||
|
||||
// Whitelist so a `leg` value can be interpolated into a column name safely — it
|
||||
// never comes from raw user input, but keep the guard explicit.
|
||||
const LEGS = ['towncrier', 'discord']
|
||||
function assertLeg(leg) {
|
||||
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
async function legsFor(jobIds) {
|
||||
if (jobIds.length === 0) return new Map()
|
||||
const marks = jobIds.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT ${LEG_COLS} FROM announce_job_legs WHERE job_id IN (${marks}) ORDER BY job_id, leg`,
|
||||
jobIds,
|
||||
)
|
||||
const byJob = new Map(jobIds.map((id) => [id, []]))
|
||||
for (const row of rows) byJob.get(row.job_id).push(row)
|
||||
return byJob
|
||||
}
|
||||
|
||||
async function create(postId) {
|
||||
async function attachLegs(jobs) {
|
||||
const byJob = await legsFor(jobs.map((j) => j.id))
|
||||
for (const job of jobs) job.legs = byJob.get(job.id) || []
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
|
||||
// an empty list is legal and yields a job with nothing to deliver.
|
||||
async function create(postId, legs = []) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||
return res.insertId
|
||||
const jobId = Number(res.insertId)
|
||||
if (legs.length > 0) {
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
await query(
|
||||
`INSERT INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
||||
legs.flatMap((leg) => [jobId, leg]),
|
||||
)
|
||||
}
|
||||
return jobId
|
||||
}
|
||||
|
||||
// Add any registered legs this job is missing. A job enqueued before a module was
|
||||
// installed has no row for that module's leg, and without this it could never
|
||||
// deliver one — the worker only ever sees rows that exist.
|
||||
async function ensureLegs(jobId, legs = []) {
|
||||
if (legs.length === 0) return
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
await query(
|
||||
`INSERT IGNORE INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
||||
legs.flatMap((leg) => [jobId, leg]),
|
||||
)
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
async function findByPostId(postId) {
|
||||
@@ -28,36 +70,38 @@ async function findByPostId(postId) {
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
[postId],
|
||||
)
|
||||
return rows[0] || null
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
// Jobs with at least one leg that is due now: pending and either never scheduled
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time. Returns
|
||||
// whole jobs with every leg attached; the worker decides which legs to run, so
|
||||
// this stays one query regardless of how many legs are registered.
|
||||
async function findDue(now = new Date(), limit = 25) {
|
||||
return query(
|
||||
`SELECT ${COLS} FROM announce_jobs
|
||||
WHERE (towncrier_status = 'pending'
|
||||
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
|
||||
OR (discord_status = 'pending'
|
||||
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
|
||||
ORDER BY id ASC
|
||||
const rows = await query(
|
||||
`SELECT ${COLS} FROM announce_jobs j
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM announce_job_legs l
|
||||
WHERE l.job_id = j.id
|
||||
AND l.status = 'pending'
|
||||
AND (l.next_attempt_at IS NULL OR l.next_attempt_at <= ?))
|
||||
ORDER BY j.id ASC
|
||||
LIMIT ?`,
|
||||
[now, now, limit],
|
||||
[now, limit],
|
||||
)
|
||||
return attachLegs(rows)
|
||||
}
|
||||
|
||||
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
|
||||
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
|
||||
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||
assertLeg(leg)
|
||||
// Update one leg's row. `leg` is a bound VALUE, not an interpolated column name —
|
||||
// the reason the old leg allowlist that guarded that interpolation is gone. A
|
||||
// module's leg id could not have passed it anyway.
|
||||
async function updateLeg(jobId, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||
await query(
|
||||
`UPDATE announce_jobs SET
|
||||
${leg}_status = ?,
|
||||
${leg}_attempts = ?,
|
||||
${leg}_last_error = ?,
|
||||
${leg}_next_attempt_at = ?
|
||||
WHERE id = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
|
||||
`UPDATE announce_job_legs SET
|
||||
status = ?, attempts = ?, last_error = ?, next_attempt_at = ?
|
||||
WHERE job_id = ? AND leg = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, jobId, leg],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,4 +109,4 @@ async function setStatus(id, status) {
|
||||
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
|
||||
}
|
||||
|
||||
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
module.exports = { create, ensureLegs, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
|
||||
@@ -1,87 +1,38 @@
|
||||
// ── Announcement pipeline: pure logic ──────────────────────────────────────
|
||||
//
|
||||
// No DB, no network — just the decisions the worker and model make, kept here so
|
||||
// they are unit-testable in isolation (server/test/announceJobs.test.js):
|
||||
// • buildTownCrierText — turn a post into sidecar-safe town-crier lines
|
||||
// • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
|
||||
// / terminal, so a data problem fails fast and a transient outage retries
|
||||
// No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make,
|
||||
// kept here so they are unit-testable in isolation (server/test/announceJobs.test.js):
|
||||
// • scheduleAfter — exponential backoff schedule + the attempt cap
|
||||
// • rollupStatus — derive the parent job status from the two legs
|
||||
|
||||
const { deriveExcerpt } = require('../../utils/sanitizeHtml')
|
||||
|
||||
// Sidecar town-crier caps, mirrored from the admin route validation
|
||||
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||
// We pre-truncate to these so a published post never bounces with towncrier.error.
|
||||
const MAX_LINES = 8
|
||||
const MAX_LINE_LEN = 200
|
||||
// • rollupStatus — derive the parent job status from its legs
|
||||
// • legError — squeeze a client result into one error line
|
||||
// • baseUrl / articleUrl — the public link an announcement carries
|
||||
//
|
||||
// What used to be here and is not any more: `buildTownCrierText`,
|
||||
// `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result
|
||||
// classification belong to the leg, and a leg is a registration now
|
||||
// (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and
|
||||
// utils/discordAnnounce.js. This file is what every leg shares.
|
||||
|
||||
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
|
||||
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
|
||||
// the post's admin panel. Shared by both legs.
|
||||
// the post's admin panel. Shared by every leg.
|
||||
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
|
||||
const MAX_ATTEMPTS = BACKOFF_MS.length
|
||||
|
||||
// Trim to a hard length, appending an ellipsis only when something was cut.
|
||||
function clamp(value, max) {
|
||||
const s = String(value == null ? '' : value)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (s.length <= max) return s
|
||||
return `${s.slice(0, max - 1).trimEnd()}…`
|
||||
// The site's public base, used to build the link an announcement carries.
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// The public link that goes in the announcement. News has no per-post route
|
||||
// (App.jsx only has the /site/news list), so we link the list — matches the
|
||||
// pre-pipeline Discord announce behavior.
|
||||
function articleUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
|
||||
}
|
||||
|
||||
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
|
||||
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
|
||||
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
|
||||
function buildTownCrierText(post, { baseUrl } = {}) {
|
||||
const title = clamp(post.title, MAX_LINE_LEN)
|
||||
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
|
||||
const lines = [title]
|
||||
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
|
||||
if (excerpt) lines.push(excerpt)
|
||||
const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
|
||||
if (url) lines.push(url)
|
||||
return lines.filter(Boolean).slice(0, MAX_LINES)
|
||||
}
|
||||
|
||||
// ── Result classification ──────────────────────────────────────────────────
|
||||
// Both clients return { ok, status, error }. Map that to one of:
|
||||
// done — delivered, mark the leg done
|
||||
// retry — transient (shard restarting, bot down, network); back off + retry
|
||||
// terminal — will never succeed as-is (over caps, bad auth/config); fail now
|
||||
|
||||
function classifyTownCrier(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
const status = result ? result.status : 0
|
||||
// 400 = over the line/duration caps (a data problem — do NOT retry).
|
||||
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
|
||||
if (status === 400 || status === 401 || status === 409) {
|
||||
return { outcome: 'terminal', error: legError(result) }
|
||||
}
|
||||
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
|
||||
// configured yet), and any other 5xx are transient — retry.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
}
|
||||
|
||||
function classifyDiscord(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
// The bot's /internal/announce collapses failures (503 = not connected,
|
||||
// 400 = no news channel configured) without surfacing Discord's own
|
||||
// retry_after, so there is no reliable terminal signal to key on here. Retry
|
||||
// every failure on the shared backoff; a genuine config problem simply
|
||||
// exhausts its attempts and lands as `failed` in the admin panel, where the
|
||||
// per-leg retry button re-runs it after the channel is set.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
function articleUrl(base) {
|
||||
return `${String(base || '').replace(/\/+$/, '')}/site/news`
|
||||
}
|
||||
|
||||
// Every leg's client returns { ok, status, data, error }. Squeeze a failure into
|
||||
// the one line stored in announce_job_legs.last_error and shown in the panel.
|
||||
function legError(result) {
|
||||
if (!result) return 'no response'
|
||||
if (result.status) {
|
||||
@@ -99,29 +50,32 @@ function scheduleAfter(attempts) {
|
||||
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
|
||||
}
|
||||
|
||||
// Parent job status derived from the two leg statuses:
|
||||
// done — both legs delivered
|
||||
// failed — both legs gave up
|
||||
// partial — at least one leg reached a terminal state while the other has not
|
||||
// matched it (still pending/retrying, or the opposite terminal state)
|
||||
// pending — neither leg is terminal yet
|
||||
function rollupStatus(towncrierStatus, discordStatus) {
|
||||
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
|
||||
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
|
||||
// Parent job status derived from its leg statuses:
|
||||
// done — every leg delivered
|
||||
// failed — every leg gave up
|
||||
// partial — at least one leg reached a terminal state without all of them
|
||||
// agreeing (some still pending/retrying, or a mix of done and failed)
|
||||
// pending — no leg is terminal yet
|
||||
//
|
||||
// Takes the list of leg statuses rather than two named arguments, because the leg
|
||||
// set is registered rather than fixed (MODULE_SYSTEM.md §1.8). No legs at all
|
||||
// rolls up `done`: with nothing registered there is nothing left to deliver, and
|
||||
// leaving such jobs `pending` would pile up rows the worker never touches.
|
||||
function rollupStatus(statuses) {
|
||||
const list = Array.isArray(statuses) ? statuses : []
|
||||
const terminal = (s) => s === 'done' || s === 'failed'
|
||||
if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
|
||||
if (list.every((s) => s === 'done')) return 'done'
|
||||
if (list.every((s) => s === 'failed')) return 'failed'
|
||||
if (list.some(terminal)) return 'partial'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_LINES,
|
||||
MAX_LINE_LEN,
|
||||
MAX_ATTEMPTS,
|
||||
BACKOFF_MS,
|
||||
buildTownCrierText,
|
||||
baseUrl,
|
||||
articleUrl,
|
||||
classifyTownCrier,
|
||||
classifyDiscord,
|
||||
legError,
|
||||
scheduleAfter,
|
||||
rollupStatus,
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
//
|
||||
// Sits between the DB rows and the worker: creates jobs on publish, records each
|
||||
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
|
||||
// announced_at when both legs land, and resets a leg for the admin retry button.
|
||||
// The pure decisions (backoff, rollup, classification) live in .logic.js.
|
||||
// announced_at when every leg lands, and resets a leg for the admin retry button.
|
||||
// The pure decisions (backoff, rollup) live in .logic.js; which legs exist at all
|
||||
// is modules/registries.js's answer, not this file's (MODULE_SYSTEM.md §1.8).
|
||||
|
||||
const db = require('./announceJobs.db')
|
||||
const logic = require('./announceJobs.logic')
|
||||
const registries = require('../../modules/registries')
|
||||
const posts = require('../posts/posts.model')
|
||||
const log = require('../../utils/logger')('announce')
|
||||
|
||||
// Enqueue an announcement for a freshly-published news post: one job row (both
|
||||
// legs pending, due immediately) plus a back-pointer on the post so the admin
|
||||
// panel can find it. Returns the new job id.
|
||||
// Enqueue an announcement for a freshly-published news post: one job row, one leg
|
||||
// row per registered leg (all pending, due immediately), plus a back-pointer on
|
||||
// the post so the admin panel can find it. Returns the new job id.
|
||||
async function enqueue(postId) {
|
||||
const jobId = await db.create(postId)
|
||||
const jobId = await db.create(postId, registries.announceLegIds())
|
||||
await posts.linkAnnounceJob(postId, jobId)
|
||||
log.info('announce job enqueued', { jobId, postId })
|
||||
return jobId
|
||||
@@ -43,12 +45,13 @@ async function enqueueIfNeeded(post, transition) {
|
||||
}
|
||||
}
|
||||
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of
|
||||
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump
|
||||
// the attempt count and schedule the next run (or fail the leg once the cap is
|
||||
// hit). Returns the updated job row.
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
|
||||
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
|
||||
// attempt count and schedule the next run (or fail the leg once the cap is hit).
|
||||
// Returns the updated job row.
|
||||
async function recordOutcome(job, leg, { outcome, error }) {
|
||||
const attempts = Number(job[`${leg}_attempts`]) || 0
|
||||
const row = (job.legs || []).find((l) => l.leg === leg)
|
||||
const attempts = Number(row && row.attempts) || 0
|
||||
|
||||
if (outcome === 'done') {
|
||||
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
|
||||
@@ -71,12 +74,12 @@ async function recordOutcome(job, leg, { outcome, error }) {
|
||||
return refreshStatus(job.id)
|
||||
}
|
||||
|
||||
// Recompute and persist the parent status from the two legs; stamp the post's
|
||||
// announced_at the moment both legs have delivered.
|
||||
// Recompute and persist the parent status from the legs; stamp the post's
|
||||
// announced_at the moment every leg has delivered.
|
||||
async function refreshStatus(jobId) {
|
||||
const job = await db.findById(jobId)
|
||||
if (!job) return null
|
||||
const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
|
||||
const status = logic.rollupStatus(job.legs.map((l) => l.status))
|
||||
if (status !== job.status) await db.setStatus(jobId, status)
|
||||
job.status = status
|
||||
if (status === 'done') {
|
||||
@@ -93,16 +96,33 @@ async function refreshStatus(jobId) {
|
||||
// the worker pick it up on the next tick. Resets the attempt count so a retry
|
||||
// after a config fix gets a full budget again.
|
||||
async function resetLeg(postId, leg) {
|
||||
if (!db.LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
if (!registries.announceLeg(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
const job = await db.findByPostId(postId)
|
||||
if (!job) return null
|
||||
// A job enqueued before this leg was registered has no row for it; create it so
|
||||
// the retry button works on an existing post after a module is installed.
|
||||
await db.ensureLegs(job.id, [leg])
|
||||
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
|
||||
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
|
||||
return refreshStatus(job.id)
|
||||
// Labelled, because this is the response body the admin panel re-renders from.
|
||||
return withLabels(await refreshStatus(job.id))
|
||||
}
|
||||
|
||||
// Decorate a job's legs with the label their registration carries, so the admin
|
||||
// panel renders a module's leg with a real name and no client change
|
||||
// (MODULE_SYSTEM.md §1.8). An unregistered leg — a stale row from a module that
|
||||
// was since removed — keeps its id as the label rather than disappearing.
|
||||
function withLabels(job) {
|
||||
if (!job) return job
|
||||
job.legs = (job.legs || []).map((l) => {
|
||||
const registered = registries.announceLeg(l.leg)
|
||||
return { ...l, label: registered ? registered.label : l.leg }
|
||||
})
|
||||
return job
|
||||
}
|
||||
|
||||
async function getByPostId(postId) {
|
||||
return db.findByPostId(postId)
|
||||
return withLabels(await db.findByPostId(postId))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -113,4 +133,5 @@ module.exports = {
|
||||
refreshStatus,
|
||||
resetLeg,
|
||||
getByPostId,
|
||||
withLabels,
|
||||
}
|
||||
|
||||
59
server/src/model/modules/modules.db.js
Normal file
59
server/src/model/modules/modules.db.js
Normal file
@@ -0,0 +1,59 @@
|
||||
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 }
|
||||
183
server/src/model/modules/modules.model.js
Normal file
183
server/src/model/modules/modules.model.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// 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,
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// Per-user push-notification subscriptions (which streams a user opted into;
|
||||
// applied to every device they register). The catalog is config/notificationStreams.
|
||||
// applied to every device they register). The catalog is core's plus every
|
||||
// installed module's, so it is read back through modules/registries rather than
|
||||
// from a config file (MODULE_SYSTEM.md §1.8).
|
||||
|
||||
const db = require('./notificationSubs.db')
|
||||
const { isValidStream } = require('../../config/notificationStreams')
|
||||
const { isValidStream } = require('../../modules/registries')
|
||||
|
||||
const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { pool, query } = require('../../core')
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// 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('../../core')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../core').logger('atlas')
|
||||
const log = require('../../utils/logger')('shardAtlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
//
|
||||
208
server/src/modules/lifecycle.js
Normal file
208
server/src/modules/lifecycle.js
Normal file
@@ -0,0 +1,208 @@
|
||||
// ── Module lifecycle dispatch ──────────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
|
||||
// docs/website/MODULE_API.md §2.5 (the hooks and when they run) and §4.4
|
||||
// (failure is a state), plus MODULE_SYSTEM.md §2.4 (what a boot does to
|
||||
// `installed_modules`).
|
||||
//
|
||||
// This is the database half of the loader, and it is a separate file for the
|
||||
// same reason modules/schema.js is: `scripts/routeManifest.js` and
|
||||
// `swagger/swagger.js` both require app.js with the pool pointed at a dead port,
|
||||
// so loader.js may not reach the database. Everything here runs from server.js,
|
||||
// after ensureSchema() has proved the database is up.
|
||||
//
|
||||
// The two halves meet at exactly one place — `loader.setState()` — so the
|
||||
// in-memory record that the §4.5 dispatch guard reads and the row the admin
|
||||
// panel reads are moved together and cannot disagree.
|
||||
//
|
||||
// Two properties this file exists to keep:
|
||||
//
|
||||
// 1. **A module's boot failure costs that module and nothing else** (§4.4).
|
||||
// Its routes stay mounted and answer 503; the site comes up; the next
|
||||
// module boots as if nothing happened.
|
||||
// 2. **The row is a record of what happened, never the source of truth for
|
||||
// what is mounted** (§2.4). Nothing here mounts, unmounts or re-scans. It
|
||||
// reads the outcome of a scan that already happened and writes it down.
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
// §2.5: shutdown races the process being killed, so a module that will not let
|
||||
// go is logged and skipped rather than allowed to hang the exit. `onBoot` has no
|
||||
// such budget on purpose — it delays the listener binding, which is the feature.
|
||||
const SHUTDOWN_BUDGET_MS = 5000
|
||||
|
||||
/**
|
||||
* Run one database call for one module without letting it become everyone's
|
||||
* failure. Returns null on failure, having logged it.
|
||||
*
|
||||
* The boot path is the whole reason this exists. A row that will not update is
|
||||
* bad — the admin panel shows the wrong thing — but it is strictly less bad than
|
||||
* a site that will not start, and it must not stop the modules after it from
|
||||
* booting.
|
||||
*/
|
||||
async function safe(what, fn) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
log.error(`module bookkeeping failed: ${what}`, { error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile `installed_modules` with what the loader found, then run every
|
||||
* surviving module's `onBoot`.
|
||||
*
|
||||
* Called once from server.js, after `ensureSchema()` (so a module's own tables
|
||||
* exist) and `seedDefaults()`, and **before the HTTP listener binds** — a module
|
||||
* that must not serve traffic until it has warmed a cache gets that for free
|
||||
* (§2.5).
|
||||
*
|
||||
* The order of the four steps is the whole design:
|
||||
*
|
||||
* 1. `beginBoot()` clears the last boot's outcomes, so what is on display
|
||||
* afterwards is what THIS boot did. `disabled` rows are left alone: that is
|
||||
* an operator decision, not an outcome (§2.4).
|
||||
* 2. Every module on the volume gets a row, written with null provenance if it
|
||||
* does not have one — a directory placed on the volume by hand is a
|
||||
* supported install (§2.4/§2.5), and without a row it could never be
|
||||
* disabled or shown as failed.
|
||||
* 3. Rows with no directory are marked failed, because step 1 has just reset
|
||||
* them to `enabled` and a row claiming to be enabled for a module that is
|
||||
* not there is the one state that is simply untrue. (A plain uninstall
|
||||
* leaves `disabled`, which step 1 never touches, so this only catches a
|
||||
* directory deleted by hand.)
|
||||
* 4. The outcome each module already carries — disabled by the operator,
|
||||
* failed during load or schema replay, or ready — is written down, and only
|
||||
* then is `onBoot` dispatched.
|
||||
*
|
||||
* Never throws. @param {object} [deps] injection seam for tests.
|
||||
*/
|
||||
async function boot({ modules, model } = {}) {
|
||||
/* eslint-disable global-require */
|
||||
const loader = modules || require('./loader')
|
||||
const rows = model || require('../model/modules/modules.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// Not an error, and the same guard replayFragments carries: a process that
|
||||
// never required app.js has no scan to reconcile against, and writing rows
|
||||
// from an empty list would mark every installed module as missing.
|
||||
if (!loader.isLoaded()) {
|
||||
log.info('no module scan in this process — skipping module boot')
|
||||
return
|
||||
}
|
||||
|
||||
const scanned = loader.list()
|
||||
|
||||
await safe('resetting last boot\'s outcomes', () => rows.beginBoot())
|
||||
|
||||
for (const m of scanned) {
|
||||
await safe(`recording module "${m.id}"`, () => rows.recordInstalled({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
// Null provenance is what a hand-placed directory looks like. An install
|
||||
// performed through the admin panel (§2.5, a later phase) writes the row
|
||||
// with its source and hash first; this refresh deliberately does not
|
||||
// overwrite either, because recordInstalled leaves what it is not given.
|
||||
}))
|
||||
}
|
||||
|
||||
const stored = (await safe('reading module rows', () => rows.list())) || []
|
||||
const onVolume = new Set(scanned.map((m) => m.id))
|
||||
|
||||
for (const row of stored) {
|
||||
if (onVolume.has(row.id) || row.state === 'disabled') continue
|
||||
await safe(`marking module "${row.id}" missing`, () => rows.markStartupFailed(row.id, {
|
||||
stage: 'require',
|
||||
reason: 'module directory not present on the volume',
|
||||
}))
|
||||
}
|
||||
|
||||
const disabled = new Set(stored.filter((r) => r.state === 'disabled').map((r) => r.id))
|
||||
|
||||
for (const m of scanned) {
|
||||
// The operator's switch wins over everything, including a failure. Its
|
||||
// routes 404 from here on (§4.5 — the leg that was unreachable until this
|
||||
// PR), it is not booted, and its failure is not re-recorded: overwriting a
|
||||
// deliberate `disabled` with an outcome would silently re-enable it on the
|
||||
// next boot.
|
||||
if (disabled.has(m.id)) {
|
||||
loader.setState(m.id, 'disabled')
|
||||
continue
|
||||
}
|
||||
if (m.state === 'startup_failed') {
|
||||
// Already failed in load() or the schema replay — both of which ran before
|
||||
// the database was available to write it down. This is where it lands.
|
||||
await safe(`recording failure for "${m.id}"`, () => rows.markStartupFailed(m.id, {
|
||||
stage: m.stage,
|
||||
reason: m.reason,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const { id, hook, ctx } of loader.bootable()) {
|
||||
try {
|
||||
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the
|
||||
// listener, which is the contract's promise to a module that must warm up
|
||||
// before it serves. Core's own boot steps are awaited the same way.
|
||||
if (hook) await hook(ctx)
|
||||
loader.setState(id, 'started')
|
||||
await safe(`marking module "${id}" started`, () => rows.markStarted(id))
|
||||
log.info(`module "${id}" started`)
|
||||
} catch (err) {
|
||||
// §4.4's second column: the routes are already mounted, so they stay
|
||||
// mounted and answer 503. A module that failed to warm up serving
|
||||
// half-initialised data is worse than one that says it is down.
|
||||
loader.setState(id, 'startup_failed', { stage: 'boot', reason: err.message })
|
||||
await safe(`recording boot failure for "${id}"`, () => rows.markStartupFailed(id, {
|
||||
stage: 'boot',
|
||||
reason: err.message,
|
||||
}))
|
||||
log.error(`module "${id}" onBoot failed — its routes will answer 503`, {
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject if `fn`'s promise has not settled within `ms`. */
|
||||
function withBudget(fn, ms) {
|
||||
let timer
|
||||
const budget = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`onShutdown exceeded its ${ms}ms budget`)), ms)
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), budget]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every started module's `onShutdown`, in reverse registration order.
|
||||
*
|
||||
* Called from server.js's signal handler before anything core owns is closed, so
|
||||
* a module still has a working database pool and push dispatcher to flush
|
||||
* through. Reverse order is the mirror of boot order: a module that booted after
|
||||
* another may be holding something the earlier one handed it.
|
||||
*
|
||||
* Never throws, and never hangs: each hook gets `SHUTDOWN_BUDGET_MS`, after
|
||||
* which it is logged and abandoned. Abandoned, not cancelled — nothing can stop
|
||||
* a promise that is still running — but the process is exiting anyway, and the
|
||||
* alternative is a shard host where `systemctl stop` hangs until SIGKILL.
|
||||
*/
|
||||
async function shutdown({ modules, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = modules || require('./loader')
|
||||
if (!loader.isLoaded()) return
|
||||
|
||||
for (const { id, hook } of loader.shutdownHooks()) {
|
||||
try {
|
||||
await withBudget(hook, budgetMs)
|
||||
log.info(`module "${id}" shut down`)
|
||||
} catch (err) {
|
||||
log.warn(`module "${id}" onShutdown failed or timed out — continuing`, {
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { boot, shutdown, SHUTDOWN_BUDGET_MS }
|
||||
@@ -1,9 +1,7 @@
|
||||
// ── 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.
|
||||
// Phase 2, PR 2 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
|
||||
// docs/website/MODULE_API.md Part 4; where the two disagree, the contract wins.
|
||||
//
|
||||
// The one property this file exists to guarantee, and the reason it looks the
|
||||
// way it does:
|
||||
@@ -12,18 +10,35 @@
|
||||
// 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.
|
||||
// invisible to the frozen-URL-surface test (§1.12). So: readdirSync at require
|
||||
// time, no database, no promises (§4.1).
|
||||
//
|
||||
// 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.
|
||||
// boot (§4.4).
|
||||
//
|
||||
// What is deliberately NOT here yet, each landing with the PR that first calls
|
||||
// it (§2.7): GET /api/v1/public/modules and the client chunk's static mount
|
||||
// (PRs 6-7).
|
||||
//
|
||||
// PR 3 added the fragment half of the schema story: this file VALIDATES a
|
||||
// fragment (statement by statement, at load time, before anything is mounted)
|
||||
// and publishes it through `fragments()`. Replaying it needs a database, so it
|
||||
// belongs to modules/schema.js, which utils/db.js calls after core's schema.
|
||||
//
|
||||
// PR 5 added the lifecycle hooks a module registers here (`onBoot`/`onShutdown`)
|
||||
// and the failure STAGE carried beside every reason. Dispatching those hooks and
|
||||
// reconciling `installed_modules` need a database, so they live in
|
||||
// modules/lifecycle.js for the same reason schema.js is a separate file: this one
|
||||
// stays require-able against a dead pool.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { MODULE_API_VERSION } = require('./version')
|
||||
const semver = require('./semver')
|
||||
const registries = require('./registries')
|
||||
const { splitStatements } = require('../utils/sqlStatements')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
@@ -41,35 +56,26 @@ const MANIFEST_KEYS = new Set([
|
||||
'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'],
|
||||
}
|
||||
// Extension slots are declared by core, at require time, in the router that owns
|
||||
// the resource (registries.declareSlot). The loader asks the registry which exist
|
||||
// rather than keeping a list, for the same reason the prefix check probes the
|
||||
// live tier routers: a second copy of the answer is a copy that drifts.
|
||||
|
||||
// id → record. Populated by scan(), read by mountInto/boot/shutdown/list.
|
||||
// id → record. Populated by load(), read by list().
|
||||
const modules = new Map()
|
||||
let scanned = false
|
||||
let loaded = 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.
|
||||
// Everything a module may reach in core, and nothing else (§2.3). Required
|
||||
// lazily inside the factory rather than at file scope: this file 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.
|
||||
// two reasons (§7.2).
|
||||
//
|
||||
// 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
|
||||
@@ -97,9 +103,9 @@ function buildCtx(id, moduleRoot) {
|
||||
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.
|
||||
// Narrowed on purpose (§2.3): utils/auth also re-exports signToken,
|
||||
// setAuthCookie and 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 },
|
||||
@@ -143,6 +149,11 @@ function buildApi(record) {
|
||||
if (record.called.has(name)) throw new Error(`${name}() called twice`)
|
||||
record.called.add(name)
|
||||
}
|
||||
const hook = (name) => (fn) => {
|
||||
once(name)
|
||||
if (typeof fn !== 'function') throw new Error(`${name}: expected a function`)
|
||||
record.hooks[name] = fn
|
||||
}
|
||||
return {
|
||||
registerRoutes(mounts) {
|
||||
once('registerRoutes')
|
||||
@@ -156,26 +167,50 @@ function buildApi(record) {
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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
|
||||
// The three de-entanglement registries (§2.4). They live in registries.js
|
||||
// rather than here because core registers through the same staging area, and
|
||||
// core has no `api` object.
|
||||
//
|
||||
// These STAGE. Nothing a module registers is visible to core until the
|
||||
// second pass commits it, for the reason the second pass exists at all: a
|
||||
// module that throws halfway through register(), or fails checkDeclared
|
||||
// after it, must leave nothing behind. A half-registered stream catalog
|
||||
// would be worse than a missing one — it would be a subscribable stream
|
||||
// nothing will ever publish to.
|
||||
registerExtension: record.staged.registerExtension,
|
||||
registerNotificationStreams(streams) {
|
||||
once('registerNotificationStreams')
|
||||
record.staged.registerNotificationStreams(streams)
|
||||
},
|
||||
registerAnnounceLeg: record.staged.registerAnnounceLeg,
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
// never calls them.
|
||||
onBoot: hook('onBoot'),
|
||||
onShutdown: hook('onShutdown'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Throw with the §4.3 step that failed attached.
|
||||
*
|
||||
* `installed_modules.failure_stage` exists so the admin panel can say *where* a
|
||||
* module broke and not only what the message was, and the model enumerates the
|
||||
* eight stages (`FAILURE_STAGES`). The steps that share one function — a
|
||||
* manifest read that also checks `coreApi`, the mounts and the slots — cannot be
|
||||
* told apart by position in load(), so they carry their own label; everything
|
||||
* else is inferred from how far load() had got. An untagged error is recorded
|
||||
* against the step that was running, never guessed at.
|
||||
*/
|
||||
function fail(stage, message) {
|
||||
const err = new Error(message)
|
||||
err.stage = stage
|
||||
throw err
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -202,68 +237,135 @@ function coreTableNames() {
|
||||
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()
|
||||
// The only leading verbs a fragment may use — an allowlist, not a DROP denylist.
|
||||
//
|
||||
// §2.6 bans `DROP`, but a denylist only ever bans what somebody thought of, and
|
||||
// core's own schema.sql needs exactly four verbs: CREATE, ALTER, INSERT, UPDATE.
|
||||
// Anything else in a file that is REPLAYED ON EVERY BOOT is a mistake worth
|
||||
// failing on — TRUNCATE and DELETE would empty a table every restart, RENAME
|
||||
// would break on the second one, and GRANT/SET/USE are core's business, not a
|
||||
// module's. CREATE covers CREATE INDEX as well as CREATE TABLE.
|
||||
//
|
||||
// This is a leading-verb check and says so: `ALTER TABLE x DROP COLUMN y` passes
|
||||
// it. Catching that needs a SQL parser, which is a large dependency to take on
|
||||
// for a rule whose real job is stopping the obvious foot-gun early.
|
||||
const ALLOWED_VERBS = new Set(['CREATE', 'ALTER', 'INSERT', 'UPDATE'])
|
||||
|
||||
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}_"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const CREATE_TABLE_ANY = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i
|
||||
const CREATE_TABLE_GUARDED = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+/i
|
||||
|
||||
/** Record which tables a module owns, so the next module can be checked against it. */
|
||||
/**
|
||||
* Read and validate a module's schema fragment; return every table it declares.
|
||||
*
|
||||
* Validation happens HERE, at load time, and not in modules/schema.js where the
|
||||
* fragment is replayed, because every rule §2.6 states is knowable by reading
|
||||
* the file — no database required. Failing at load means a module with a bad
|
||||
* fragment never mounts at all (§4.4's first column: routes and nav simply
|
||||
* absent), rather than mounting, 503ing, and leaving whatever its fragment did
|
||||
* manage to execute behind it.
|
||||
*
|
||||
* Throws if the file is unreadable or breaks a rule.
|
||||
*/
|
||||
function tablesOf(dir, manifest) {
|
||||
if (!manifest.schema) return new Set()
|
||||
const sql = fs.readFileSync(path.join(dir, manifest.schema), 'utf8')
|
||||
const file = path.join(dir, manifest.schema)
|
||||
const sql = fs.readFileSync(file, 'utf8')
|
||||
|
||||
for (const statement of splitStatements(sql)) {
|
||||
const verb = (statement.match(/^\w+/) || [''])[0].toUpperCase()
|
||||
if (!ALLOWED_VERBS.has(verb)) {
|
||||
throw new Error(`schema fragment statement starts with "${verb}" (allowed: ${[...ALLOWED_VERBS].join(', ')})`)
|
||||
}
|
||||
// A bare CREATE TABLE succeeds exactly once and fails every boot after it,
|
||||
// which presents as a module that worked until the first restart.
|
||||
if (CREATE_TABLE_ANY.test(statement) && !CREATE_TABLE_GUARDED.test(statement)) {
|
||||
throw new Error('schema fragment has a CREATE TABLE without IF NOT EXISTS')
|
||||
}
|
||||
}
|
||||
|
||||
return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
|
||||
}
|
||||
|
||||
function readManifest(dir, id) {
|
||||
function checkTableNames(id, tables) {
|
||||
const allowed = LEGACY_TABLE_PREFIXES[id] || []
|
||||
const core = coreTableNames()
|
||||
|
||||
for (const table of tables) {
|
||||
if (core.has(table)) throw new Error(`schema fragment declares core table "${table}"`)
|
||||
for (const other of modules.values()) {
|
||||
if (other.tables.has(table)) {
|
||||
throw new Error(`schema fragment declares "${table}", already owned by module "${other.id}"`)
|
||||
}
|
||||
}
|
||||
const prefixed = table.startsWith(`${id}_`) || allowed.some((p) => table.startsWith(p))
|
||||
if (!prefixed) throw new Error(`schema fragment table "${table}" is not prefixed "${id}_"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does core already own this prefix in this tier?
|
||||
*
|
||||
* Asked of the LIVE tier router rather than a hardcoded list, so the check
|
||||
* cannot drift the first time core adds a capability router — the spike's
|
||||
* hardcoded table was already one prefix stale when it was written. Modules are
|
||||
* loaded after every core mount, so the stack is complete by the time this runs,
|
||||
* and `layer.match` is express's own matcher rather than a second-guess at its
|
||||
* regexp grammar.
|
||||
*
|
||||
* Root-mounted layers are skipped: `use(noindex, requireAuth)` and the two
|
||||
* `use('/', singletonRouter)` mounts match every path, and counting them would
|
||||
* report every prefix as taken.
|
||||
*/
|
||||
function ownedByCore(tierRouter, prefix) {
|
||||
return (tierRouter.stack || []).some(
|
||||
(layer) => layer.regexp && !layer.regexp.fast_slash && layer.match(prefix),
|
||||
)
|
||||
}
|
||||
|
||||
function readManifest(dir, id, tierRouters) {
|
||||
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 (!MANIFEST_KEYS.has(key)) fail('manifest', `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 (!ID.test(manifest.id || '')) fail('manifest', `invalid id "${manifest.id}"`)
|
||||
if (manifest.id !== id) fail('manifest', `id "${manifest.id}" does not match directory "${id}"`)
|
||||
if (!manifest.version) fail('manifest', 'missing version')
|
||||
if (!manifest.coreApi) fail('core_api', 'missing coreApi')
|
||||
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
|
||||
throw new Error(`needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
|
||||
fail('core_api', `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`)
|
||||
if (!TIERS.includes(tier)) fail('mounts', `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`)
|
||||
if (!PREFIX.test(prefix)) fail('mounts', `bad prefix "${prefix}" in mounts.${tier}`)
|
||||
if (ownedByCore(tierRouters[tier], prefix)) {
|
||||
fail('mounts', `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}"`)
|
||||
fail('mounts', `prefix ${tier}${prefix} already registered by module "${other.id}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const slot of manifest.extensions || []) {
|
||||
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
|
||||
}
|
||||
|
||||
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.
|
||||
fail('schema', 'declares schema but no purge')
|
||||
}
|
||||
if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) {
|
||||
fail('schema', `purge file "${manifest.purge}" is missing`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
@@ -278,16 +380,35 @@ function checkDeclared(record) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scan ───────────────────────────────────────────────────────────────────
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Discover, validate, register and mount every module under MODULES_DIR.
|
||||
*
|
||||
* **Called exactly once, explicitly, from app.js**, after the three tier routers
|
||||
* are required and before the app is exported. There is no lazy self-scan: the
|
||||
* spike's was lazy and silent, so requiring the loader and reading the module
|
||||
* list gave an empty array and no error (MODULE_API.md §7.6). Everything that
|
||||
* reads the module list now throws until this has run.
|
||||
*
|
||||
* The ordering is not incidental. Core's mounts must already be on the tier
|
||||
* routers, because that is what the prefix-collision check is asked about; and
|
||||
* modules mount after them, so first-match-wins means a module could not shadow
|
||||
* a core prefix even if the check were bypassed.
|
||||
*
|
||||
* Safe to call when the modules directory does not exist — that is the normal
|
||||
* case for a bare core, and it is the state this PR ships in.
|
||||
*
|
||||
* @param {{public: Router, admin: Router, player: Router}} tierRouters
|
||||
*/
|
||||
function scan() {
|
||||
if (scanned) return
|
||||
scanned = true
|
||||
function load(tierRouters) {
|
||||
if (loaded) return
|
||||
for (const tier of TIERS) {
|
||||
if (!tierRouters || typeof tierRouters[tier] !== 'function') {
|
||||
throw new Error(`modules.load: missing the "${tier}" tier router`)
|
||||
}
|
||||
}
|
||||
loaded = true
|
||||
|
||||
let entries = []
|
||||
try {
|
||||
@@ -309,23 +430,37 @@ function scan() {
|
||||
dir,
|
||||
manifest: null,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
staged: registries.stage(id),
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
onBoot: null,
|
||||
onShutdown: null,
|
||||
hooks: { onBoot: null, onShutdown: null },
|
||||
ctx: null,
|
||||
state: 'installed',
|
||||
stage: null,
|
||||
reason: null,
|
||||
}
|
||||
|
||||
// How far load() has got, so an untagged throw is recorded against the step
|
||||
// that was actually running (§4.3's steps 5-7). The steps before it label
|
||||
// themselves, because readManifest covers four of them in one pass.
|
||||
let stage = 'manifest'
|
||||
try {
|
||||
record.manifest = readManifest(dir, id)
|
||||
record.manifest = readManifest(dir, id, tierRouters)
|
||||
stage = 'schema'
|
||||
record.tables = tablesOf(dir, record.manifest)
|
||||
checkTableNames(id, record.tables)
|
||||
if (record.manifest.server) {
|
||||
const entry = path.join(dir, record.manifest.server)
|
||||
stage = 'require'
|
||||
// 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))
|
||||
stage = 'register'
|
||||
// Kept on the record, not discarded after register(): §2.5 hands the
|
||||
// same ctx to onBoot, and building a second one would be a second frozen
|
||||
// object claiming to be the same handle.
|
||||
record.ctx = buildCtx(id, dir)
|
||||
register(record.ctx, buildApi(record))
|
||||
checkDeclared(record)
|
||||
}
|
||||
record.state = 'registered'
|
||||
@@ -337,32 +472,57 @@ function scan() {
|
||||
// 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.stage = err.stage || stage
|
||||
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 })
|
||||
log.error(`module "${id}" failed to load — continuing without it`, {
|
||||
stage: record.stage,
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mounting is a SECOND pass, after every module has been validated, and not
|
||||
// because it reads better. `ownedByCore` asks the live tier router what is
|
||||
// already on it, so mounting inside the loop would make the first module's
|
||||
// layers indistinguishable from core's — the second module claiming a taken
|
||||
// prefix would be told it collided with core, naming the wrong culprit, and
|
||||
// the module-versus-module check below it could never be reached.
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
// Commit what this module staged. Collisions with core or with an earlier
|
||||
// module surface here, in scan order, and cost only this module.
|
||||
registries.apply(record.staged.staged)
|
||||
} catch (err) {
|
||||
record.state = 'startup_failed'
|
||||
record.stage = 'register'
|
||||
record.reason = err.message
|
||||
log.error(`module "${record.id}" failed to register — continuing without it`, {
|
||||
reason: err.message,
|
||||
})
|
||||
continue // unmounted, exactly like a validation failure in the first pass
|
||||
}
|
||||
mount(record, tierRouters)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
* Mount one module's routers onto the tier routers, behind the dispatch guard.
|
||||
*
|
||||
* The guard is the other half of §4.4. A module that fails BEFORE this point has
|
||||
* no routes at all; one that fails after — schema replay (PR 3), `onBoot`
|
||||
* (PR 5) — keeps its URLs and answers 503, so `routes.manifest.json` never
|
||||
* depends on whether a boot hook happened to succeed on the machine that
|
||||
* generated it. `disabled` is 404 and unreachable until PR 5 wires
|
||||
* `installed_modules` in; it is written here because the guard is the contract's
|
||||
* §4.5, not a later addition.
|
||||
*/
|
||||
function mountInto(tier, tierRouter) {
|
||||
scan()
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered' && record.state !== 'started') continue
|
||||
function mount(record, tierRouters) {
|
||||
for (const tier of TIERS) {
|
||||
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) => {
|
||||
tierRouters[tier].use(prefix, (req, res, next) => {
|
||||
if (record.state === 'startup_failed') {
|
||||
return res.status(503).json({ message: 'Module unavailable' })
|
||||
}
|
||||
@@ -373,123 +533,131 @@ function mountInto(tier, tierRouter) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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
|
||||
}
|
||||
// The states a loaded record may hold, deliberately a hardcoded subset rather
|
||||
// than an import of model/modules/modules.model.js's STATES: that model reaches
|
||||
// the database, and this file must stay require-able against a dead one.
|
||||
// `installed` is not here because a record leaves load() resolved either way.
|
||||
const RECORD_STATES = new Set(['registered', 'started', 'disabled', 'startup_failed'])
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Move a loaded module to a new state — the POST-mount transitions.
|
||||
*
|
||||
* 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).
|
||||
* Called by whoever ran the step that failed or the step that succeeded, because
|
||||
* only they can know: `ensureSchema()` replays the fragments (PR 3), and
|
||||
* lifecycle.js runs `onBoot` and reconciles `installed_modules` (whose `disabled`
|
||||
* rows are what make the guard's 404 leg reachable).
|
||||
*
|
||||
* The stage travels with the reason and is cleared by every non-failing move,
|
||||
* for the same reason the database columns are (§2.4): a running module must
|
||||
* never be able to show a stale failure.
|
||||
*
|
||||
* Unknown ids are ignored rather than thrown on: a module can be absent from the
|
||||
* volume and still have a row, and a caller on the boot path must not turn that
|
||||
* into everyone's failure.
|
||||
*/
|
||||
function markFailed(id, reason) {
|
||||
function setState(id, state, { stage = null, reason = null } = {}) {
|
||||
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
record.state = state
|
||||
record.stage = state === 'startup_failed' ? stage : null
|
||||
record.reason = state === 'startup_failed' ? reason : null
|
||||
}
|
||||
|
||||
// ── Introspection ──────────────────────────────────────────────────────────
|
||||
|
||||
function assertLoaded(caller) {
|
||||
if (!loaded) throw new Error(`modules.${caller}() before modules.load()`)
|
||||
}
|
||||
|
||||
/**
|
||||
* What GET /api/v1/public/modules, the HTML shell and the admin panel read.
|
||||
* Has load() run in this process?
|
||||
*
|
||||
* `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.
|
||||
* The one legitimate reason to ask instead of just calling an accessor: a
|
||||
* process that never required app.js and so has no module list to be wrong
|
||||
* about. `npm run seed` (db/seed.js) is exactly that — it calls ensureSchema()
|
||||
* standalone, and the fragment replay has to be able to tell "this is the seed
|
||||
* script" from "the server booted and something is mis-ordered", which is the
|
||||
* distinction §7.6's throw exists to preserve everywhere else.
|
||||
*/
|
||||
const isLoaded = () => loaded
|
||||
|
||||
/**
|
||||
* Every module found on the volume, loaded or failed, in scan order.
|
||||
*
|
||||
* Throws rather than returning `[]` when load() has not run — the empty list is
|
||||
* a real answer for a core with no modules installed, and a caller cannot tell
|
||||
* the two apart (§7.6).
|
||||
*/
|
||||
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,
|
||||
}
|
||||
})
|
||||
assertLoaded('list')
|
||||
return [...modules.values()].map((r) => ({
|
||||
id: r.id,
|
||||
name: r.manifest.name || r.id,
|
||||
version: r.manifest.version,
|
||||
state: r.state,
|
||||
stage: r.stage,
|
||||
reason: r.reason,
|
||||
capabilities: r.manifest.capabilities || [],
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules that are ready to be booted, with their hook, in scan order.
|
||||
*
|
||||
* `registered` only — the state a module holds between a clean load and its
|
||||
* `onBoot`. One that failed validation or schema replay is not going to run, and
|
||||
* one already `started` has run. A module with no `onBoot` is still listed: it
|
||||
* has nothing to warm up, but it still has to reach `started` so the admin panel
|
||||
* and `installed_modules` agree with the guard about what is serving.
|
||||
*
|
||||
* @returns {{id: string, hook: Function|null, ctx: object|null}[]}
|
||||
*/
|
||||
function bootable() {
|
||||
assertLoaded('bootable')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'registered')
|
||||
.map((r) => ({ id: r.id, hook: r.hooks.onBoot, ctx: r.ctx }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The shutdown hooks to run, in REVERSE registration order (§2.5).
|
||||
*
|
||||
* `started` only. A module whose `onBoot` threw is mid-way through a warm-up it
|
||||
* never finished, and calling its `onShutdown` would hand it a half-built world
|
||||
* to tear down — the one thing worse than not closing cleanly. Reverse order is
|
||||
* the same reasoning applied between modules rather than within one.
|
||||
*
|
||||
* @returns {{id: string, hook: Function}[]}
|
||||
*/
|
||||
function shutdownHooks() {
|
||||
assertLoaded('shutdownHooks')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'started' && r.hooks.onShutdown)
|
||||
.map((r) => ({ id: r.id, hook: r.hooks.onShutdown }))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every schema fragment waiting to be replayed, in scan order.
|
||||
*
|
||||
* `registered` only: a module that failed validation must not get its tables
|
||||
* created (it is not going to run), and one already `started` has had them. The
|
||||
* absolute path is resolved here rather than handed out as a manifest-relative
|
||||
* name, so the replay never has to know how a module directory is laid out.
|
||||
*
|
||||
* @returns {{id: string, file: string}[]}
|
||||
*/
|
||||
function fragments() {
|
||||
assertLoaded('fragments')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'registered' && r.manifest.schema)
|
||||
.map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) }))
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}
|
||||
module.exports = { load, list, setState, fragments, bootable, shutdownHooks, isLoaded, dir }
|
||||
|
||||
358
server/src/modules/registries.js
Normal file
358
server/src/modules/registries.js
Normal file
@@ -0,0 +1,358 @@
|
||||
// ── The de-entanglement registries ─────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 4 of docs/website/MODULE_SYSTEM.md §2.7 — the three seams §1.8 and
|
||||
// §1.9 identified, where core code and game-specific content are tangled in one
|
||||
// file and a folder move cannot separate them. The normative contract is
|
||||
// docs/website/MODULE_API.md §2.4.
|
||||
//
|
||||
// The three:
|
||||
//
|
||||
// 1. `registerExtension(slot, router)` — §1.9. Module routes hanging off a
|
||||
// CORE resource (`/admin/users/:id`), so all six shard sub-paths keep their
|
||||
// URLs while core never learns what "shard" means.
|
||||
// 2. `registerNotificationStreams(streams)` — §1.8. The push-stream catalog:
|
||||
// push INFRASTRUCTURE is core, this CATALOG is content.
|
||||
// 3. `registerAnnounceLeg({ leg, label, dispatch, classify })` — §1.8. The news
|
||||
// dispatcher's delivery legs; Discord is core, town crier is content.
|
||||
//
|
||||
// **Core registers through these functions too, and is the only registrant until
|
||||
// Phase 3.** `registerCore()` below is called explicitly from app.js before
|
||||
// `modules.load()` — explicit, never lazy, the same decision the loader's trigger
|
||||
// took (MODULE_API.md §7.6). Core going through the same door is the point: a
|
||||
// registry only core's hardcoded base bypasses is a registry whose first real
|
||||
// exercise is a module, which is the drift this PR exists to prevent.
|
||||
//
|
||||
// **Registering is validate-then-commit, per registrant.** `apply()` checks every
|
||||
// claim in a batch before it writes any of them, so a module that registers two
|
||||
// streams and then throws — or fails a later validation step in the loader — has
|
||||
// left nothing behind. That is the registry-side twin of the loader's second-pass
|
||||
// mount rule: nothing a module claims takes effect until the module as a whole is
|
||||
// known good.
|
||||
//
|
||||
// Nothing here reaches the database or the network. It is a require-time-safe
|
||||
// collection of what core and modules have declared, read at request time.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// slot → { router, filledBy }. `router` is created when CORE DECLARES the slot
|
||||
// and mounted immediately; registrants `use()` into it later. That indirection is
|
||||
// not optional: users.router.js is required while app.js is being built, long
|
||||
// before any module has been scanned, so the thing core mounts has to be a stable
|
||||
// object that can still be empty.
|
||||
const slots = new Map()
|
||||
|
||||
// Registration order, which is display order in the app's notifications screen.
|
||||
const streams = []
|
||||
const streamOwners = new Map() // stream id → owner id, for the collision message
|
||||
|
||||
// leg id → { owner, leg, label, dispatch, classify }
|
||||
const legs = new Map()
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
// prefix — the exact counterpart of the loader's LEGACY_TABLE_PREFIXES, for the
|
||||
// exact same reason. These seven ids are stored in `notification_subs` rows and
|
||||
// are read by a shipped Android client; renaming them in Phase 3 would be a data
|
||||
// migration and a client break, so `uo` keeps them and the prefix rule stays real
|
||||
// for every module written after it.
|
||||
const LEGACY_STREAM_IDS = {
|
||||
uo: [
|
||||
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
|
||||
'vendor.sale', 'house.idoc', 'account.login',
|
||||
],
|
||||
}
|
||||
|
||||
// Likewise for announce legs: `towncrier` is a stored value in
|
||||
// announce_job_legs.leg and the body of the admin retry endpoint.
|
||||
const LEGACY_LEGS = { uo: ['towncrier'] }
|
||||
|
||||
const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/
|
||||
const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
||||
|
||||
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
||||
// grandfathered names are the ones that predate all of this.
|
||||
function namespaced(owner, name, legacy) {
|
||||
return owner === 'core' || name.startsWith(`${owner}.`) || (legacy[owner] || []).includes(name)
|
||||
}
|
||||
|
||||
// ── Extension slots (§1.9) ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Core declares an extension slot and gets the router to mount for it.
|
||||
*
|
||||
* ONLY core may declare a slot; a module may only fill one (MODULE_API.md §2.4).
|
||||
* That asymmetry is why this is not on the `api` object handed to a module.
|
||||
*
|
||||
* `mergeParams` so the slot's router sees the parent's `:id`. Core's own routes
|
||||
* on the resource are declared before the slot is mounted, so first-match-wins
|
||||
* gives core the path conflict, as the contract requires.
|
||||
*
|
||||
* @returns {import('express').Router} mount this at the resource, once.
|
||||
*/
|
||||
function declareSlot(slot) {
|
||||
if (slots.has(slot)) throw new Error(`extension slot "${slot}" already declared`)
|
||||
const router = express.Router({ mergeParams: true })
|
||||
slots.set(slot, { router, filledBy: null })
|
||||
return router
|
||||
}
|
||||
|
||||
/** Does this slot exist? The loader asks, to validate `extensions` in a manifest. */
|
||||
const hasSlot = (slot) => slots.has(slot)
|
||||
|
||||
/** Who filled a slot, or null. */
|
||||
const slotFilledBy = (slot) => (slots.get(slot) || {}).filledBy || null
|
||||
|
||||
/**
|
||||
* Every FILLED slot, for the OpenAPI build step (swagger/slotSpecs.js).
|
||||
*
|
||||
* `router` is the slot's own stable router — the object mounted on the resource —
|
||||
* so the build can find it in the live express stack and recover the prefix it
|
||||
* hangs at without a hardcoded table.
|
||||
*/
|
||||
const filledSlots = () =>
|
||||
[...slots.entries()]
|
||||
.filter(([, e]) => e.filledBy)
|
||||
.map(([slot, e]) => ({ slot, filledBy: e.filledBy, router: e.router, specFile: e.specFile || null }))
|
||||
|
||||
// ── Notification streams (§1.8) ────────────────────────────────────────────
|
||||
|
||||
/** The whole catalog, core's entries first, in registration order. */
|
||||
const allStreams = () => streams.slice()
|
||||
|
||||
/** Is this a stream anyone registered? Gates a subscription write. */
|
||||
const isValidStream = (id) => streamOwners.has(id)
|
||||
|
||||
/** Ids of the owner-keyed streams — those needing a linked game account. */
|
||||
const personalStreams = () => new Set(streams.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// ── Announce legs (§1.8) ───────────────────────────────────────────────────
|
||||
|
||||
/** Every registered leg, in registration order. */
|
||||
const announceLegs = () => [...legs.values()]
|
||||
|
||||
/** Just the ids — the enqueue order and the retry endpoint's allowlist. */
|
||||
const announceLegIds = () => [...legs.keys()]
|
||||
|
||||
/** One leg, or null. */
|
||||
const announceLeg = (leg) => legs.get(leg) || null
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
// schema-fragment validation: what can be decided from the argument alone is
|
||||
// decided AT THE CALL, so the error carries the registrant's own stack. What
|
||||
// depends on other registrants has to wait for the batch to be complete.
|
||||
|
||||
function checkStreamShape(entry) {
|
||||
if (!entry || !STREAM_ID.test(entry.id || '')) {
|
||||
throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`)
|
||||
}
|
||||
if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`)
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
description: entry.description || '',
|
||||
personal: Boolean(entry.personal),
|
||||
requiresLinkedAccount: Boolean(entry.requiresLinkedAccount),
|
||||
}
|
||||
}
|
||||
|
||||
function checkLegShape(entry) {
|
||||
const { leg, label, dispatch, classify } = entry || {}
|
||||
if (!LEG_ID.test(leg || '')) throw new Error(`registerAnnounceLeg: bad leg id "${leg}"`)
|
||||
if (typeof dispatch !== 'function') throw new Error(`announce leg "${leg}" has no dispatch()`)
|
||||
if (typeof classify !== 'function') throw new Error(`announce leg "${leg}" has no classify()`)
|
||||
return { leg, label: label || leg, dispatch, classify }
|
||||
}
|
||||
|
||||
// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
|
||||
// router reaches the app through declareSlot(), which no static parse of app.js
|
||||
// can follow, so swagger-autogen would silently drop every route in it — the
|
||||
// spike's exact failure (MODULE_API.md §7.4). Core names the file so
|
||||
// `npm run swagger` can generate a fragment from it and merge it into the
|
||||
// committed spec. A MODULE has no equivalent need: it ships a prebuilt
|
||||
// `swagger-fragment.json` in its bundle (§6.1a), because core never has its
|
||||
// sources to analyse.
|
||||
function checkExtensionShape(slot, router, specFile) {
|
||||
if (!slots.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
|
||||
if (typeof router !== 'function') throw new Error(`registerExtension: ${slot} is not a router`)
|
||||
return { slot, router, specFile: specFile || null }
|
||||
}
|
||||
|
||||
// ── Staging + commit ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A registrant's staging area: shape-checked claims, not yet visible to anyone.
|
||||
*
|
||||
* The loader hands one of these to a module through `api`, and `registerCore()`
|
||||
* builds one for core. Nothing a registrant says is readable through
|
||||
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [] }
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerNotificationStreams: expected an array')
|
||||
for (const e of entries) staged.streams.push(checkStreamShape(e))
|
||||
},
|
||||
registerAnnounceLeg(entry) {
|
||||
staged.legs.push(checkLegShape(entry))
|
||||
},
|
||||
registerExtension(slot, router, specFile) {
|
||||
staged.extensions.push(checkExtensionShape(slot, router, specFile))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a staged batch against everything already registered, then commit it.
|
||||
*
|
||||
* Validation is TOTAL before the first write, so this either takes all of a
|
||||
* registrant's claims or none of them. Throws on the first collision, naming who
|
||||
* holds the thing already — which is the message an operator needs and the one
|
||||
* PR 2 learned to protect (mounting inside the scan loop made every collision
|
||||
* look like it was with core).
|
||||
*/
|
||||
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions }) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
for (const s of newStreams) {
|
||||
const held = streamOwners.get(s.id)
|
||||
if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`)
|
||||
if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`)
|
||||
if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) {
|
||||
throw new Error(`stream "${s.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenStreams.add(s.id)
|
||||
}
|
||||
|
||||
const seenLegs = new Set()
|
||||
for (const l of newLegs) {
|
||||
const held = legs.get(l.leg)
|
||||
if (held) throw new Error(`announce leg "${l.leg}" is already registered by "${held.owner}"`)
|
||||
if (seenLegs.has(l.leg)) throw new Error(`announce leg "${l.leg}" registered twice`)
|
||||
if (!namespaced(owner, l.leg, LEGACY_LEGS)) {
|
||||
throw new Error(`announce leg "${l.leg}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenLegs.add(l.leg)
|
||||
}
|
||||
|
||||
const seenSlots = new Set()
|
||||
for (const x of newExtensions) {
|
||||
const entry = slots.get(x.slot)
|
||||
if (entry.filledBy) {
|
||||
throw new Error(`extension slot "${x.slot}" is already filled by "${entry.filledBy}"`)
|
||||
}
|
||||
if (seenSlots.has(x.slot)) throw new Error(`extension slot "${x.slot}" filled twice`)
|
||||
seenSlots.add(x.slot)
|
||||
}
|
||||
|
||||
// ── commit — nothing below can fail ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
streams.push(s)
|
||||
}
|
||||
for (const l of newLegs) legs.set(l.leg, { owner, ...l })
|
||||
for (const x of newExtensions) {
|
||||
const entry = slots.get(x.slot)
|
||||
entry.filledBy = owner
|
||||
entry.specFile = x.specFile
|
||||
entry.router.use(x.router)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register everything CORE owns, through the same staging area a module uses.
|
||||
*
|
||||
* Called once from app.js, before `modules.load()` — before, because a module's
|
||||
* collision checks are asked against what is already registered, and core's
|
||||
* claims must be the ones already there.
|
||||
*
|
||||
* What is here is what survives Phase 3. Everything after the boundary comment is
|
||||
* shard content and leaves with module-uo, registered rather than hardcoded so
|
||||
* the seam is exercised on every boot long before a module first uses it.
|
||||
*/
|
||||
function registerCore() {
|
||||
if (coreRegistered) return
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const coreStreams = require('../config/coreStreams')
|
||||
const discordLeg = require('../utils/discordAnnounce')
|
||||
const shardStreams = require('../config/shardStreams')
|
||||
const townCrierLeg = require('../utils/shardAnnounce')
|
||||
const shardExtension = require('../router/v1/admin/usersShard.router')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const api = stage('core')
|
||||
api.registerNotificationStreams(coreStreams.STREAMS)
|
||||
api.registerAnnounceLeg(discordLeg.leg)
|
||||
|
||||
// ── Phase 3 boundary ────────────────────────────────────────────────────
|
||||
// These three lines become module-uo's register() body, with 'core' becoming
|
||||
// 'uo'. Nothing else in core has to change for that to happen — which is the
|
||||
// whole claim PR 4 is making.
|
||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
||||
// The third argument is core-only and has no module counterpart — see
|
||||
// checkExtensionShape. A module ships a prebuilt swagger-fragment.json instead.
|
||||
api.registerExtension('admin.users.detail', shardExtension, require.resolve('../router/v1/admin/usersShard.router'))
|
||||
|
||||
apply(api.staged)
|
||||
coreRegistered = true
|
||||
|
||||
log.info('core registrations complete', {
|
||||
streams: streams.length,
|
||||
announceLegs: legs.size,
|
||||
extensions: [...slots.keys()].filter(slotFilledBy),
|
||||
})
|
||||
}
|
||||
|
||||
/** Has registerCore() run? Read by tests, and by the loader's ordering assertion. */
|
||||
const isCoreRegistered = () => coreRegistered
|
||||
|
||||
// Test-only: hand the process back. Registries are process-global by design
|
||||
// (there is one core), so a test that registers has to be able to undo it.
|
||||
//
|
||||
// Slot DECLARATIONS survive, and only their fills are cleared: a slot is declared
|
||||
// at require time by the router that owns the resource, and that require has
|
||||
// already happened and will not happen again in this process. Clearing the map
|
||||
// would leave a slot that nothing can re-declare. The cost is that a test filling
|
||||
// the same slot twice stacks two routers inside it; no test reads through a slot
|
||||
// router, so that is left rather than papered over with a rebuilt router that
|
||||
// would no longer be the object users.router.js mounted.
|
||||
function _reset() {
|
||||
for (const entry of slots.values()) {
|
||||
entry.filledBy = null
|
||||
entry.specFile = null
|
||||
}
|
||||
streams.length = 0
|
||||
streamOwners.clear()
|
||||
legs.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
declareSlot,
|
||||
hasSlot,
|
||||
slotFilledBy,
|
||||
filledSlots,
|
||||
allStreams,
|
||||
isValidStream,
|
||||
personalStreams,
|
||||
announceLegs,
|
||||
announceLegIds,
|
||||
announceLeg,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
isCoreRegistered,
|
||||
_reset,
|
||||
}
|
||||
84
server/src/modules/schema.js
Normal file
84
server/src/modules/schema.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// ── Module schema fragment replay ──────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
|
||||
// docs/website/MODULE_API.md §2.6 (fragments) and §4.4 (failure is a state).
|
||||
//
|
||||
// utils/db.js calls replayFragments() once, immediately after core's schema.sql
|
||||
// is in place and before seedDefaults(), so that by the time a module's onBoot
|
||||
// runs (PR 5) its tables exist.
|
||||
//
|
||||
// The split of responsibility with loader.js is worth stating, because it is the
|
||||
// reason there are two files:
|
||||
//
|
||||
// loader.js VALIDATES a fragment — at load time, with no database, before
|
||||
// anything is mounted. Every rule §2.6 states about the SQL is
|
||||
// knowable by reading it, so a fragment that breaks one costs the
|
||||
// module its mount entirely (§4.4, first column).
|
||||
// schema.js EXECUTES it. Only reachable failures live here: the database
|
||||
// rejecting a statement it could not have known was bad. Those are
|
||||
// post-mount, so they 503 (§4.4, second column).
|
||||
//
|
||||
// The property this file exists to keep: **a fragment that fails takes down its
|
||||
// own module and nothing else.** Not core's boot, not another module's tables.
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const { splitStatements } = require('../utils/sqlStatements')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
/**
|
||||
* Replay every installed module's schema fragment, in scan order.
|
||||
*
|
||||
* Never throws. A module whose fragment fails is moved to `startup_failed` with
|
||||
* the database's own message as the reason, its routes answer 503 through the
|
||||
* dispatch guard the loader already mounted, and the next module is replayed as
|
||||
* if nothing happened.
|
||||
*
|
||||
* Partial application is accepted rather than compensated for: MariaDB commits
|
||||
* each DDL statement implicitly, so a fragment failing at statement three has
|
||||
* already created the first two tables and no wrapping transaction could undo
|
||||
* them. Since every statement is required to be idempotent (§2.6), the fix is
|
||||
* for the operator to correct the fragment and reboot — the surviving tables are
|
||||
* re-CREATE-IF-NOT-EXISTSed harmlessly and the replay carries on past them.
|
||||
*
|
||||
* @param {object} [deps] injection seam for tests — the whole point of this
|
||||
* function taking arguments at all, since the server suite runs with the pool
|
||||
* pointed at a dead port.
|
||||
* @param {(sql: string) => Promise<any>} [deps.query]
|
||||
* @param {object} [deps.modules] the loader
|
||||
*/
|
||||
async function replayFragments({ query, modules } = {}) {
|
||||
/* eslint-disable global-require */
|
||||
const run = query || require('../utils/db').query
|
||||
const loader = modules || require('./loader')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// Not an error: `npm run seed` calls ensureSchema() without ever requiring
|
||||
// app.js, so no scan has happened and there is genuinely nothing to replay.
|
||||
// Logged rather than silently skipped — the one thing that must not happen is
|
||||
// a booting server quietly getting no module tables (§7.6).
|
||||
if (!loader.isLoaded()) {
|
||||
log.info('no module scan in this process — skipping schema fragment replay')
|
||||
return
|
||||
}
|
||||
|
||||
for (const { id, file } of loader.fragments()) {
|
||||
try {
|
||||
const statements = splitStatements(fs.readFileSync(file, 'utf8'))
|
||||
for (const statement of statements) {
|
||||
// Serially, and awaited: a fragment's ALTER TABLE routinely depends on
|
||||
// the CREATE TABLE above it.
|
||||
await run(statement)
|
||||
}
|
||||
log.info(`schema ensured for module "${id}"`, { statements: statements.length })
|
||||
} catch (err) {
|
||||
loader.setState(id, 'startup_failed', { stage: 'schema', reason: err.message })
|
||||
log.error(`module "${id}" schema fragment failed — its routes will answer 503`, {
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { replayFragments }
|
||||
@@ -728,6 +728,23 @@ async function listUsers(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
||||
//
|
||||
// Lived in usersShard.controller.js until PR 4, purely because the detail page it
|
||||
// backs is mostly shard panels — MODULE_SYSTEM.md §1.9 called that out as core
|
||||
// semantics that ended up in the UO controller by proximity. Reading a user is
|
||||
// core's, and it stays here when the shard panels leave.
|
||||
async function getUser(req, res) {
|
||||
try {
|
||||
const user = await users.getById(Number(req.params.id))
|
||||
if (!user) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(user)
|
||||
} catch (err) {
|
||||
log.error('getUser', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(req, res) {
|
||||
try {
|
||||
if (await users.getRawByUsername(req.body.username)) {
|
||||
@@ -938,6 +955,7 @@ module.exports = {
|
||||
ASSET_RULES,
|
||||
listActivity,
|
||||
listUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
|
||||
@@ -15,7 +15,6 @@ 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')
|
||||
@@ -75,11 +74,6 @@ 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus
|
||||
// the announcement pipeline (town crier + Discord) status and retry.
|
||||
// the announcement pipeline status and retry.
|
||||
//
|
||||
// Mounted at /api/v1/admin/posts by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the
|
||||
@@ -13,6 +13,7 @@ const { body, param } = require('express-validator')
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const registries = require('../../../modules/registries')
|
||||
|
||||
const postsRouter = express.Router()
|
||||
|
||||
@@ -124,14 +125,17 @@ postsRouter.get(
|
||||
postsRouter.post(
|
||||
'/:id/announce/retry',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||||
// #swagger.summary = 'Retry one announcement delivery leg'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", description: "A registered delivery leg id, as returned by GET /announce." } }, required: ["leg"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('leg').isIn(['towncrier', 'discord']),
|
||||
// The allowlist is the REGISTERED leg set, read per request rather than
|
||||
// captured at require time: this file is required while app.js is being built,
|
||||
// before registerCore() and modules.load() have run (MODULE_SYSTEM.md §1.8).
|
||||
body('leg').custom((leg) => registries.announceLeg(leg) != null).withMessage('unknown announce leg'),
|
||||
validate,
|
||||
ctrl.retryAnnounceLeg,
|
||||
)
|
||||
|
||||
@@ -15,17 +15,7 @@
|
||||
// admin needs to be told what is wrong with their path, and a 500 says only
|
||||
// "something broke".
|
||||
|
||||
// ⚠ 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 atlas = require('../../../model/shardAtlas/shardAtlas.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-shard-atlas')
|
||||
|
||||
@@ -4,20 +4,18 @@
|
||||
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
|
||||
// and moderators manage content and reports, never accounts.
|
||||
//
|
||||
// Handlers still live in admin.controller.js (users) and usersShard.controller.js
|
||||
// (uo-link footprint); this PR re-wires routes, not logic.
|
||||
// Handlers live in admin.controller.js. The shard footprint that used to be
|
||||
// wired here is now an EXTENSION SLOT (MODULE_SYSTEM.md §1.9) — see the bottom of
|
||||
// this file.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const registries = require('../../../modules/registries')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
// Same shape the shard routes validate account names with.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
|
||||
const usersRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
@@ -151,11 +149,6 @@ usersRouter.post(
|
||||
ctrl.resetUserMfa,
|
||||
)
|
||||
|
||||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||
// already grants admins a bypass to any account), so no routes for them here.
|
||||
usersRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
@@ -166,84 +159,21 @@ usersRouter.get(
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getUser,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
usersRouter.get(
|
||||
'/:id/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
usersRouter.delete(
|
||||
'/:id/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
ctrl.getUser,
|
||||
)
|
||||
|
||||
// ── The `admin.users.detail` extension slot (MODULE_SYSTEM.md §1.9) ────────
|
||||
//
|
||||
// A module may hang routes off this core resource. Core DECLARES the slot; only
|
||||
// core may, and a module may only fill one (MODULE_API.md §2.4). What fills it
|
||||
// today is core's own usersShard.router.js, registered in registries.js's
|
||||
// registerCore() — the shard footprint that used to be wired inline right here.
|
||||
// Phase 3 changes the registrant, not this line.
|
||||
//
|
||||
// LAST, deliberately: every core route on the resource is already declared, so
|
||||
// first-match-wins means core owns any path conflict. The router is created at
|
||||
// declare time and filled later, because this file is required while app.js is
|
||||
// still being built — long before a module has been scanned.
|
||||
usersRouter.use('/:id', registries.declareSlot('admin.users.detail'))
|
||||
|
||||
module.exports = usersRouter
|
||||
|
||||
@@ -25,18 +25,6 @@ async function accountsForUser(id) {
|
||||
return { user, links, accounts: links.map((l) => l.account) }
|
||||
}
|
||||
|
||||
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
||||
async function getUser(req, res) {
|
||||
try {
|
||||
const user = await users.getById(Number(req.params.id))
|
||||
if (!user) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(user)
|
||||
} catch (err) {
|
||||
log.error('getUser', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
@@ -139,4 +127,4 @@ async function unlinkAccount(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
|
||||
111
server/src/router/v1/admin/usersShard.router.js
Normal file
111
server/src/router/v1/admin/usersShard.router.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// ── The `admin.users.detail` extension slot's contents ─────────────────────
|
||||
//
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.9 named the
|
||||
// fourth mount shape: module routes hanging off a CORE resource. These six paths
|
||||
// are shard reads on `/admin/users/:id`, a user-management URL core owns, so
|
||||
// they cannot move with a prefix and cannot stay where they are either.
|
||||
//
|
||||
// The resolution is an extension SLOT. `users.router.js` declares
|
||||
// `admin.users.detail` and mounts its router at `/:id`; this file is what fills
|
||||
// it, registered through modules/registries.js like a module would
|
||||
// (registerCore() → `api.registerExtension('admin.users.detail', …)`). Phase 3
|
||||
// moves this file to module-uo and changes nothing else — the six URLs are
|
||||
// identical either way, and core never learns what "shard" means.
|
||||
//
|
||||
// `mergeParams` comes from the slot's router, so `req.params.id` is the parent's
|
||||
// user id. Core's own routes on the resource are declared BEFORE the slot is
|
||||
// mounted, so core always wins a path conflict (MODULE_API.md §2.4).
|
||||
|
||||
const express = require('express')
|
||||
const { param } = require('express-validator')
|
||||
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
// Same shape the shard routes validate account names with.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
|
||||
const shardRouter = express.Router({ mergeParams: true })
|
||||
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||
// already grants admins a bypass to any account), so no routes for them here.
|
||||
shardRouter.get(
|
||||
'/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
shardRouter.delete(
|
||||
'/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
)
|
||||
|
||||
module.exports = shardRouter
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
const pushDevices = require('../../../model/pushDevices/pushDevices.model')
|
||||
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
|
||||
const { STREAMS } = require('../../../config/notificationStreams')
|
||||
const registries = require('../../../modules/registries')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
|
||||
const log = require('../../../utils/logger')('notifications')
|
||||
@@ -49,9 +49,11 @@ async function removeDevice(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/streams — the subscribable catalog (static).
|
||||
// GET /auth/me/notifications/streams — the subscribable catalog: core's streams
|
||||
// plus every installed module's, in registration order. Fixed for the lifetime of
|
||||
// a process (registration is boot-time), not a static constant.
|
||||
function getStreams(req, res) {
|
||||
return res.json({ streams: STREAMS })
|
||||
return res.json({ streams: registries.allStreams() })
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.
|
||||
|
||||
@@ -21,7 +21,6 @@ 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')
|
||||
@@ -41,8 +40,4 @@ 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
|
||||
|
||||
@@ -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/visibility')
|
||||
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
|
||||
const visibility = require('../../../utils/shardVisibility')
|
||||
|
||||
const log = require('../core').logger('public-atlas')
|
||||
const log = require('../../../utils/logger')('public-atlas')
|
||||
|
||||
const FEATURE = 'atlas'
|
||||
|
||||
@@ -16,17 +16,13 @@
|
||||
// The default audience is `anonymous`, so these gates are inert until an admin
|
||||
// changes something.
|
||||
|
||||
// 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 { requireFeature } = require('../utils/visibility')
|
||||
const express = require('express')
|
||||
const { param, query } = require('express-validator')
|
||||
|
||||
const { express, validator, middleware } = core
|
||||
const { param, query } = validator
|
||||
const { siteMode, validate } = middleware
|
||||
const atlas = require('./atlas.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { requireFeature } = require('../../../utils/shardVisibility')
|
||||
|
||||
const atlasRouter = express.Router()
|
||||
|
||||
@@ -17,12 +17,11 @@
|
||||
|
||||
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()
|
||||
@@ -34,16 +33,11 @@ publicRouter.use('/wiki', wikiRouter)
|
||||
publicRouter.use('/pages', pagesRouter)
|
||||
// Live shard data, never site-mode gated.
|
||||
publicRouter.use('/shard', shardRouter)
|
||||
|
||||
// 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 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)
|
||||
|
||||
// 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,9 +14,10 @@ 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 modules = require('./modules/loader')
|
||||
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
|
||||
const shardMarket = require('./model/shardMarket/shardMarket.model')
|
||||
const moduleLifecycle = require('./modules/lifecycle')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||
const brand = require('./config/brand')
|
||||
@@ -80,15 +81,16 @@ async function start() {
|
||||
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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()
|
||||
// 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()
|
||||
|
||||
// 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:
|
||||
@@ -108,6 +110,15 @@ async function start() {
|
||||
const mode = await settings.get('site_mode')
|
||||
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
||||
|
||||
// Reconcile installed_modules with what the loader found on the volume at
|
||||
// require time, then run each module's onBoot (MODULE_API.md §2.5). Placed
|
||||
// after core's own boot work and BEFORE the listener binds, for both reasons
|
||||
// the contract gives: a module's warm-up may depend on core being up, and a
|
||||
// module that must not serve traffic until it has warmed a cache gets that
|
||||
// guarantee only if nothing is listening yet. Never throws — a module that
|
||||
// fails here keeps its URLs and answers 503.
|
||||
await moduleLifecycle.boot()
|
||||
|
||||
const server = http.createServer(app)
|
||||
server.listen(PORT, HOST, () => {
|
||||
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
|
||||
@@ -173,11 +184,16 @@ function setupShutdown(server, internalServer) {
|
||||
if (closing) return
|
||||
closing = true
|
||||
log.warn(`${signal} received — shutting down gracefully`)
|
||||
// Modules first, while everything they were handed still works: the database
|
||||
// pool, the push dispatcher and the SSE fan-out are all still open here, and
|
||||
// a module's onShutdown is the only chance it gets to flush through them
|
||||
// (MODULE_API.md §2.5). Each hook is budgeted, so one that will not let go
|
||||
// costs five seconds rather than the whole shutdown.
|
||||
await moduleLifecycle.shutdown()
|
||||
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||
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 {
|
||||
|
||||
@@ -2,59 +2,35 @@
|
||||
//
|
||||
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every
|
||||
// ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
|
||||
// enqueued or past their backoff — and dispatches each one:
|
||||
// • town crier → uoLinkClient.postTownCrier (sidecar → in-game)
|
||||
// • discord → botInternalClient.announce (bot → #news channel)
|
||||
// Both clients never throw (they return { ok, status, error }); the model turns
|
||||
// each result into done / retry / terminal and owns the backoff + rollup. One
|
||||
// leg failing never touches the other. Same setInterval + unref + stop() shape
|
||||
// as middleware/botScore's sweeper, wired into server.js start/shutdown.
|
||||
// enqueued or past their backoff — and dispatches each one through the leg that
|
||||
// registered itself for that id (modules/registries.js). Core registers
|
||||
// `discord`; module-uo registers `towncrier`; another game's module registers its
|
||||
// own, and nothing in this file changes.
|
||||
//
|
||||
// A leg's client never throws (they return { ok, status, error }) and its
|
||||
// classify() turns that into done / retry / terminal, which the model converts to
|
||||
// backoff + rollup. One leg failing never touches another. Same setInterval +
|
||||
// unref + stop() shape as middleware/botScore's sweeper, wired into server.js
|
||||
// start/shutdown.
|
||||
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
const announceJobsDb = require('../model/announceJobs/announceJobs.db')
|
||||
const logic = require('../model/announceJobs/announceJobs.logic')
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const botInternalClient = require('./botInternalClient')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('./logger')('announce-worker')
|
||||
|
||||
const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000
|
||||
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
|
||||
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// ── Leg dispatchers ─────────────────────────────────────────────────────────
|
||||
// Return the raw client result ({ ok, status, data, error }); classification is
|
||||
// the model/logic's job.
|
||||
|
||||
async function dispatchTownCrier(post) {
|
||||
const lines = logic.buildTownCrierText(post, { baseUrl: baseUrl() })
|
||||
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
|
||||
// than stacking a duplicate, so a retry after a partial failure is safe.
|
||||
return uoLinkClient.postTownCrier({
|
||||
id: `post-${post.id}`,
|
||||
lines,
|
||||
durationSec: TOWNCRIER_DURATION_SEC,
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchDiscord(post) {
|
||||
const base = baseUrl()
|
||||
// Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
|
||||
// absolute URL.
|
||||
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
|
||||
return botInternalClient.announce({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: `${base}/site/news`,
|
||||
imageUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// Process a single due leg of a job: fetch the post, dispatch, classify, record.
|
||||
async function processLeg(job, leg) {
|
||||
const registered = registries.announceLeg(leg)
|
||||
if (!registered) {
|
||||
// A row for a leg nobody registers any more (its module was removed). Leave
|
||||
// it alone: failing it would make the job roll up terminal on the strength of
|
||||
// a leg that no longer exists, and reinstalling the module should resume it.
|
||||
return
|
||||
}
|
||||
|
||||
const post = await posts.getById(job.post_id)
|
||||
if (!post) {
|
||||
// Post was deleted between enqueue and dispatch (the CASCADE usually reaps
|
||||
@@ -63,16 +39,9 @@ async function processLeg(job, leg) {
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
let classification
|
||||
try {
|
||||
if (leg === 'towncrier') {
|
||||
result = await dispatchTownCrier(post)
|
||||
classification = logic.classifyTownCrier(result)
|
||||
} else {
|
||||
result = await dispatchDiscord(post)
|
||||
classification = logic.classifyDiscord(result)
|
||||
}
|
||||
classification = registered.classify(await registered.dispatch(post))
|
||||
} catch (err) {
|
||||
// Clients shouldn't throw, but if one does, treat it as a transient failure
|
||||
// rather than crashing the tick.
|
||||
@@ -83,11 +52,10 @@ async function processLeg(job, leg) {
|
||||
await announceJobs.recordOutcome(job, leg, classification)
|
||||
}
|
||||
|
||||
// One sweep: find due jobs and process each due leg. A job may have both legs due
|
||||
// (a fresh enqueue) — process the ones that are actually pending. `job` is a
|
||||
// snapshot from the SELECT; recordOutcome re-reads for the rollup, so processing
|
||||
// the two legs sequentially off the same snapshot is fine (each leg only writes
|
||||
// its own columns).
|
||||
// One sweep: find due jobs and process each due leg. A job may have several legs
|
||||
// due at once (a fresh enqueue). `job` is a snapshot from the SELECT;
|
||||
// recordOutcome re-reads for the rollup, so processing the legs sequentially off
|
||||
// the same snapshot is fine (each leg only writes its own row).
|
||||
async function tick(now = new Date()) {
|
||||
let jobs
|
||||
try {
|
||||
@@ -99,15 +67,15 @@ async function tick(now = new Date()) {
|
||||
if (!jobs || jobs.length === 0) return
|
||||
|
||||
for (const job of jobs) {
|
||||
if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier')
|
||||
if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord')
|
||||
for (const row of job.legs || []) {
|
||||
if (isLegDue(row, now)) await processLeg(job, row.leg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLegDue(job, leg, now) {
|
||||
if (job[`${leg}_status`] !== 'pending') return false
|
||||
const next = job[`${leg}_next_attempt_at`]
|
||||
return next == null || new Date(next) <= now
|
||||
function isLegDue(row, now) {
|
||||
if (!row || row.status !== 'pending') return false
|
||||
return row.next_attempt_at == null || new Date(row.next_attempt_at) <= now
|
||||
}
|
||||
|
||||
let timer = null
|
||||
@@ -118,7 +86,7 @@ function start() {
|
||||
tick().catch((err) => log.error('announce tick failed', { message: err.message }))
|
||||
}, POLL_MS)
|
||||
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
|
||||
log.info('announcement dispatcher started', { pollMs: POLL_MS })
|
||||
log.info('announcement dispatcher started', { pollMs: POLL_MS, legs: registries.announceLegIds() })
|
||||
return timer
|
||||
}
|
||||
|
||||
@@ -129,4 +97,4 @@ function stop() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord }
|
||||
module.exports = { start, stop, tick, processLeg, isLegDue }
|
||||
|
||||
@@ -4,6 +4,7 @@ const mariadb = require('mariadb')
|
||||
require('dotenv').config()
|
||||
|
||||
const log = require('./logger')('db')
|
||||
const { splitStatements } = require('./sqlStatements')
|
||||
|
||||
const pool = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
@@ -41,67 +42,37 @@ 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).
|
||||
* Once core's schema is in place, every installed module's schema fragment is
|
||||
* replayed after it (MODULE_API.md §2.6). That step is deliberately OUTSIDE the
|
||||
* retry loop: a fragment that throws is that module's failure, not a signal the
|
||||
* database is still coming up, and retrying core's whole schema nine more times
|
||||
* because one module shipped bad SQL would turn a 503'd module into a two-minute
|
||||
* boot. It is also why this file knows nothing about modules beyond the one call
|
||||
* below — the discovery, splitting and per-module failure handling all live in
|
||||
* modules/schema.js, required lazily so that requiring the pool never drags the
|
||||
* loader in with it.
|
||||
*/
|
||||
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
|
||||
await ensureCoreSchema({ retries, delayMs })
|
||||
// eslint-disable-next-line global-require
|
||||
await require('../modules/schema').replayFragments()
|
||||
}
|
||||
|
||||
/** Core's own schema.sql, with the wait-for-the-database retry. */
|
||||
async function ensureCoreSchema({ retries, delayMs }) {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
for (const statement of statementsOf(fs.readFileSync(SCHEMA_PATH, 'utf8'))) {
|
||||
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
|
||||
for (const statement of splitStatements(sql)) {
|
||||
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()
|
||||
@@ -116,7 +87,15 @@ async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent: `pool.end()` throws "pool is already closed" on a second call, and
|
||||
// closing twice is normal rather than exceptional — a SIGINT followed by a
|
||||
// SIGTERM reaches the shutdown handler twice, and the test harness closes the
|
||||
// pool for every file on top of the suites that close it themselves. A teardown
|
||||
// that fails because it had already succeeded is noise.
|
||||
let closed = false
|
||||
async function close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
|
||||
45
server/src/utils/discordAnnounce.js
Normal file
45
server/src/utils/discordAnnounce.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// ── The Discord announce leg ───────────────────────────────────────────────
|
||||
//
|
||||
// CORE content — the Discord bot has no game logic (MODULE_SYSTEM.md §1.10), so
|
||||
// this leg stays in core when module-uo leaves with the town crier. It is written
|
||||
// in the same shape as a module's leg and registered through the same function
|
||||
// (modules/registries.js registerAnnounceLeg), because a registry only core's
|
||||
// hardcoded base bypasses is not exercised until a module arrives.
|
||||
|
||||
const botInternalClient = require('./botInternalClient')
|
||||
const { articleUrl, baseUrl, legError } = require('../model/announceJobs/announceJobs.logic')
|
||||
|
||||
// Deliver. Returns the raw client result ({ ok, status, data, error }) — the
|
||||
// client never throws, and classification is `classify`'s job.
|
||||
async function dispatch(post) {
|
||||
const base = baseUrl()
|
||||
// Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
|
||||
// absolute URL.
|
||||
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
|
||||
return botInternalClient.announce({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
url: articleUrl(base),
|
||||
imageUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// The bot's /internal/announce collapses failures (503 = not connected,
|
||||
// 400 = no news channel configured) without surfacing Discord's own retry_after,
|
||||
// so there is no reliable terminal signal to key on here. Retry every failure on
|
||||
// the shared backoff; a genuine config problem simply exhausts its attempts and
|
||||
// lands as `failed` in the admin panel, where the per-leg retry button re-runs it
|
||||
// after the channel is set.
|
||||
function classify(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
}
|
||||
|
||||
const leg = {
|
||||
leg: 'discord',
|
||||
label: 'Discord #news',
|
||||
dispatch,
|
||||
classify,
|
||||
}
|
||||
|
||||
module.exports = { leg, dispatch, classify }
|
||||
@@ -81,9 +81,8 @@ function absolutize(url) {
|
||||
* byte-identical property without a database.
|
||||
*
|
||||
* @param {string} html the built index.html
|
||||
* @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.
|
||||
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
|
||||
* effective brand assets and theme; anything absent falls back to BRAND_* env
|
||||
* @returns {string}
|
||||
*/
|
||||
function render(html, overrides = {}) {
|
||||
@@ -103,7 +102,6 @@ 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 ')
|
||||
@@ -125,27 +123,6 @@ 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
|
||||
@@ -193,18 +170,7 @@ async function get() {
|
||||
// mean a failing query per page view.
|
||||
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 })
|
||||
const html = render(template, overrides)
|
||||
// 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() }
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// ── Push-notification fan-out (content-free tickles) ───────────────────────
|
||||
//
|
||||
// The transport-agnostic publisher that turns an event into opt-in push
|
||||
// notifications. Two producers call in:
|
||||
// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams
|
||||
// (beside the existing SSE broadcast — same event source, same allowlist).
|
||||
// • the admin create/publish-post path → publish('news.post', …).
|
||||
// The transport-agnostic publisher that turns a stream id into opt-in push
|
||||
// notifications. It knows nothing about where the stream came from: the admin
|
||||
// create/publish-post path calls publish('news.post', …), and utils/shardPush.js
|
||||
// resolves a shard event to a stream and an owner and calls the same function.
|
||||
//
|
||||
// That split is MODULE_SYSTEM.md §1.8's second entanglement, inverted. This file
|
||||
// used to own `fromShardEvent()`, which required the shardLinks model and the
|
||||
// shard event mapper — core infrastructure reaching into game content. Now the
|
||||
// content side calls in, and a module reaches this through `ctx.push.publish`.
|
||||
//
|
||||
// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
|
||||
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy
|
||||
@@ -18,9 +22,7 @@
|
||||
// registration AND every publish: HTTPS only, never a private/loopback host, and
|
||||
// (when configured) the origin must be in the shard's ntfy allow-set.
|
||||
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const pushDevicesModel = require('../model/pushDevices/pushDevices.model')
|
||||
const { mapShardEvent } = require('../config/notificationStreams')
|
||||
const log = require('./logger')('push-dispatch')
|
||||
|
||||
const TIMEOUT_MS = 5000
|
||||
@@ -106,30 +108,4 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
|
||||
// owning website user via shardLinks (an unlinked account → nobody to notify).
|
||||
// Never throws — a dead relay must never affect ingest.
|
||||
async function fromShardEvent(event, deps = {}) {
|
||||
const links = deps.shardLinks || shardLinks
|
||||
const targets = mapShardEvent(event, deps.tracker)
|
||||
for (const t of targets) {
|
||||
try {
|
||||
if (t.ownerAccount) {
|
||||
let owner = null
|
||||
try {
|
||||
owner = await links.getByAccount(t.ownerAccount)
|
||||
} catch {
|
||||
owner = null
|
||||
}
|
||||
if (!owner || owner.userId == null) continue
|
||||
await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
|
||||
} else {
|
||||
await publish(t.streamId, { ref: t.ref }, deps)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publish, fromShardEvent, isAllowedEndpoint }
|
||||
module.exports = { publish, isAllowedEndpoint }
|
||||
|
||||
77
server/src/utils/shardAnnounce.js
Normal file
77
server/src/utils/shardAnnounce.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// ── The in-game town-crier announce leg ────────────────────────────────────
|
||||
//
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8's third
|
||||
// entangled file: utils/announceWorker.js is core's news dispatcher, but one of
|
||||
// its two delivery legs goes to the shard through uoLinkClient.postTownCrier.
|
||||
// PR 4 turned the legs into registrations, and this file is what module-uo will
|
||||
// register in Phase 3 — it moves whole, with `'core'` becoming `'uo'` and the
|
||||
// leg id staying `towncrier` (grandfathered in registries.js: the id is a stored
|
||||
// value in announce_job_legs.leg).
|
||||
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const { deriveExcerpt } = require('./sanitizeHtml')
|
||||
const { articleUrl, baseUrl, legError } = require('../model/announceJobs/announceJobs.logic')
|
||||
|
||||
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
|
||||
|
||||
// Sidecar town-crier caps, mirrored from the admin route validation
|
||||
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||
// We pre-truncate to these so a published post never bounces with an error.
|
||||
const MAX_LINES = 8
|
||||
const MAX_LINE_LEN = 200
|
||||
|
||||
// Trim to a hard length, appending an ellipsis only when something was cut.
|
||||
function clamp(value, max) {
|
||||
const s = String(value == null ? '' : value)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (s.length <= max) return s
|
||||
return `${s.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
|
||||
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
|
||||
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
|
||||
function buildTownCrierText(post, { baseUrl: base } = {}) {
|
||||
const title = clamp(post.title, MAX_LINE_LEN)
|
||||
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
|
||||
const lines = [title]
|
||||
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
|
||||
if (excerpt) lines.push(excerpt)
|
||||
const url = clamp(articleUrl(base), MAX_LINE_LEN)
|
||||
if (url) lines.push(url)
|
||||
return lines.filter(Boolean).slice(0, MAX_LINES)
|
||||
}
|
||||
|
||||
async function dispatch(post) {
|
||||
const lines = buildTownCrierText(post, { baseUrl: baseUrl() })
|
||||
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
|
||||
// than stacking a duplicate, so a retry after a partial failure is safe.
|
||||
return uoLinkClient.postTownCrier({
|
||||
id: `post-${post.id}`,
|
||||
lines,
|
||||
durationSec: TOWNCRIER_DURATION_SEC,
|
||||
})
|
||||
}
|
||||
|
||||
function classify(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
const status = result ? result.status : 0
|
||||
// 400 = over the line/duration caps (a data problem — do NOT retry).
|
||||
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
|
||||
if (status === 400 || status === 401 || status === 409) {
|
||||
return { outcome: 'terminal', error: legError(result) }
|
||||
}
|
||||
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
|
||||
// configured yet), and any other 5xx are transient — retry.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
}
|
||||
|
||||
const leg = {
|
||||
leg: 'towncrier',
|
||||
label: 'In-game town crier',
|
||||
dispatch,
|
||||
classify,
|
||||
}
|
||||
|
||||
module.exports = { leg, dispatch, classify, buildTownCrierText, MAX_LINES, MAX_LINE_LEN }
|
||||
@@ -19,7 +19,7 @@ const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const settingsModel = require('../model/settings/settings.model')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const pushDispatch = require('./pushDispatch')
|
||||
const shardPush = require('./shardPush')
|
||||
const defaultLog = require('./logger')('shard-ingest')
|
||||
|
||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||
@@ -262,7 +262,7 @@ function resolveDeps(deps) {
|
||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||
settings: deps.settings || settingsModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
|
||||
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
}
|
||||
|
||||
47
server/src/utils/shardPush.js
Normal file
47
server/src/utils/shardPush.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// ── Shard event → push fan-out ─────────────────────────────────────────────
|
||||
//
|
||||
// MODULE-UO CONTENT, still living in core — the inverted half of
|
||||
// MODULE_SYSTEM.md §1.8's second entangled file. `utils/pushDispatch.js` is core
|
||||
// infrastructure, but its `fromShardEvent()` required the shardLinks model and
|
||||
// the shard event mapper, which is a core file importing content. PR 4 inverted
|
||||
// it: `publish()` stays core, and this — the thing that knows what a shard event
|
||||
// is — moved out to call it. Phase 3 moves this file to module-uo whole, where it
|
||||
// will reach `publish` through `ctx.push.publish` instead of a require.
|
||||
//
|
||||
// Owner resolution is the reason this cannot just be a mapper: a personal
|
||||
// (owner-keyed) target names a GAME account, and turning that into a website user
|
||||
// needs the shardLinks model. An unlinked account is simply nobody to notify.
|
||||
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const { mapShardEvent } = require('../config/shardStreams')
|
||||
const { publish } = require('./pushDispatch')
|
||||
const log = require('./logger')('shard-push')
|
||||
|
||||
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
|
||||
// owning website user via shardLinks (an unlinked account → nobody to notify).
|
||||
// Never throws — a dead relay must never affect ingest.
|
||||
async function fromShardEvent(event, deps = {}) {
|
||||
const links = deps.shardLinks || shardLinks
|
||||
const doPublish = deps.publish || publish
|
||||
const targets = mapShardEvent(event, deps.tracker)
|
||||
for (const t of targets) {
|
||||
try {
|
||||
if (t.ownerAccount) {
|
||||
let owner = null
|
||||
try {
|
||||
owner = await links.getByAccount(t.ownerAccount)
|
||||
} catch {
|
||||
owner = null
|
||||
}
|
||||
if (!owner || owner.userId == null) continue
|
||||
await doPublish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
|
||||
} else {
|
||||
await doPublish(t.streamId, { ref: t.ref }, deps)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fromShardEvent }
|
||||
37
server/src/utils/sqlStatements.js
Normal file
37
server/src/utils/sqlStatements.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// ── Splitting a .sql file into statements ──────────────────────────────────
|
||||
//
|
||||
// Extracted from utils/db.js so that core's schema.sql and a module's schema
|
||||
// fragment are split by literally the same code. MODULE_API.md §2.6 promises a
|
||||
// fragment is replayed "statement by statement, split the same way" — with two
|
||||
// copies of this that promise would hold only until one of them was edited.
|
||||
//
|
||||
// It lives in its own file rather than being exported from utils/db.js because
|
||||
// modules/loader.js validates fragments at require time and must not pull the
|
||||
// mariadb pool into app.js's require chain to do it.
|
||||
|
||||
/**
|
||||
* Split a .sql file into individual 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 neither
|
||||
* core's schema nor a conforming fragment puts `--` inside a string literal
|
||||
* (§2.6 states that as a rule a fragment must follow).
|
||||
*
|
||||
* @param {string} sql
|
||||
* @returns {string[]} non-empty, trimmed statements in file order
|
||||
*/
|
||||
function splitStatements(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)
|
||||
}
|
||||
|
||||
module.exports = { splitStatements }
|
||||
115
server/swagger/mergeSpec.js
Normal file
115
server/swagger/mergeSpec.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ── Merging an OpenAPI fragment into a spec ────────────────────────────────
|
||||
//
|
||||
// The merge half of docs/website/MODULE_API.md §6.1's settled decision: routes
|
||||
// that reach the app through something swagger-autogen cannot statically follow
|
||||
// contribute a FRAGMENT, and core merges it.
|
||||
//
|
||||
// Two callers, one function, deliberately:
|
||||
// • build time — swagger/slotSpecs.js, for core's own extension-slot routers
|
||||
// (§1.9). Those are core routes that a static parse of app.js cannot see,
|
||||
// because the slot's router is created by registries.declareSlot() and filled
|
||||
// later. They belong in the committed `swagger-output.json`.
|
||||
// • request time (Phase 2 PR 6+) — an installed module's `swagger-fragment.json`,
|
||||
// merged over the committed spec for `/api/docs.json`.
|
||||
//
|
||||
// **Core always wins a key collision** (§6.1a). A fragment cannot redefine a path,
|
||||
// a tag or a schema core already declares; the collision is reported and the
|
||||
// fragment's version dropped. Merging is shallow-per-section — `paths`, `tags`
|
||||
// and `components.schemas` — because those are the only three sections a fragment
|
||||
// is allowed to carry, and a deeper merge would let a fragment reach into
|
||||
// `info`, `servers` or the security schemes.
|
||||
|
||||
/**
|
||||
* Merge `fragment` into `spec`, in place, with core winning every collision.
|
||||
*
|
||||
* @param {object} spec the base spec — mutated
|
||||
* @param {object} fragment `{ paths?, tags?, components?: { schemas? } }`
|
||||
* @param {string} source who the fragment came from, for the collision message
|
||||
* @returns {string[]} the collisions that were dropped (empty when clean)
|
||||
*/
|
||||
function mergeFragment(spec, fragment, source) {
|
||||
const dropped = []
|
||||
|
||||
for (const [path, item] of Object.entries(fragment.paths || {})) {
|
||||
if (spec.paths[path]) {
|
||||
// Not a merge of the two path items: a fragment adding a METHOD to a core
|
||||
// path is the same overreach as replacing it, and the extension-slot
|
||||
// contract already says core owns the resource (§2.4).
|
||||
dropped.push(`path ${path}`)
|
||||
continue
|
||||
}
|
||||
spec.paths[path] = item
|
||||
}
|
||||
|
||||
const tagNames = new Set((spec.tags || []).map((t) => t.name))
|
||||
for (const tag of fragment.tags || []) {
|
||||
if (tagNames.has(tag.name)) continue // same tag, not a collision worth reporting
|
||||
spec.tags.push(tag)
|
||||
tagNames.add(tag.name)
|
||||
}
|
||||
|
||||
const schemas = (fragment.components && fragment.components.schemas) || {}
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
if (spec.components.schemas[name]) {
|
||||
dropped.push(`schema ${name}`)
|
||||
continue
|
||||
}
|
||||
spec.components.schemas[name] = schema
|
||||
}
|
||||
|
||||
if (dropped.length > 0) {
|
||||
process.stderr.write(
|
||||
`swagger: dropped ${dropped.length} colliding key(s) from ${source} — core wins: ${dropped.join(', ')}\n`,
|
||||
)
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-root a fragment's paths under the prefix its router is actually mounted at.
|
||||
*
|
||||
* A fragment generated by pointing swagger-autogen at a router file alone has
|
||||
* paths relative to that router (`/shard/accounts`), because nothing in the file
|
||||
* says where it hangs. The prefix comes from the LIVE express stack rather than a
|
||||
* table, so it cannot drift the way a hand-written mount list would.
|
||||
*
|
||||
* Express path params (`:id`) become OpenAPI's (`{id}`) — swagger-autogen already
|
||||
* does that for the paths it generates, so the prefix has to match.
|
||||
*/
|
||||
function prefixPaths(fragment, prefix) {
|
||||
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
||||
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
||||
const paths = {}
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
paths[`${oas}${p}`] = orderParams(item, outer)
|
||||
}
|
||||
return { ...fragment, paths }
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the prefix's own path parameters first, in prefix order.
|
||||
*
|
||||
* swagger-autogen orders parameters by where they appear in the path it saw, and
|
||||
* the fragment's path is only the tail — so `/{id}/shard/link/{account}` comes
|
||||
* out as (account, id) rather than (id, account). Re-rooting the path has to
|
||||
* re-root the parameter order with it, or every slot route churns the committed
|
||||
* spec by a reorder that means nothing.
|
||||
*/
|
||||
function orderParams(item, outer) {
|
||||
for (const operation of Object.values(item)) {
|
||||
const params = operation && operation.parameters
|
||||
if (!Array.isArray(params)) continue
|
||||
const rank = (p) => {
|
||||
const i = outer.indexOf(p && p.name)
|
||||
return i === -1 ? outer.length : i
|
||||
}
|
||||
// Stable: only the prefix params move, and only ahead of the rest.
|
||||
operation.parameters = params
|
||||
.map((p, i) => ({ p, i }))
|
||||
.sort((a, b) => rank(a.p) - rank(b.p) || a.i - b.i)
|
||||
.map(({ p }) => p)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
module.exports = { mergeFragment, prefixPaths }
|
||||
120
server/swagger/slotSpecs.js
Normal file
120
server/swagger/slotSpecs.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// ── OpenAPI for core's extension-slot routers ──────────────────────────────
|
||||
//
|
||||
// The second half of `npm run swagger`. It exists because of a failure mode that
|
||||
// announces itself as a success.
|
||||
//
|
||||
// `swagger/swagger.js` is STATIC analysis: swagger-autogen parses `src/app.js` as
|
||||
// text and follows the literal `app.use(...)` mount chain. An extension slot
|
||||
// (MODULE_SYSTEM.md §1.9) breaks that chain on purpose — the slot's router is
|
||||
// created by `registries.declareSlot()` and filled later, so there is no literal
|
||||
// require for the parser to follow. When PR 4 moved the six `/admin/users/:id/shard/*`
|
||||
// routes behind the `admin.users.detail` slot, regenerating the spec printed
|
||||
// `Swagger-autogen: Success` and deleted 407 lines. Nothing failed. The spike hit
|
||||
// the identical thing (MODULE_API.md §7.4) and it is why the fragment merge is
|
||||
// the settled answer (§6.1).
|
||||
//
|
||||
// So: generate a fragment per filled slot by pointing swagger-autogen at that
|
||||
// router's own file, re-root its paths at the prefix the router is ACTUALLY
|
||||
// mounted at in the live app, and merge. Two things are deliberately derived
|
||||
// rather than written down, because a written-down copy is a copy that drifts:
|
||||
//
|
||||
// • WHICH slots — from `registries.filledSlots()`, not a list here.
|
||||
// • WHERE each hangs — by finding the slot's own router object in the live
|
||||
// express stack and accumulating the mount prefixes above it, using
|
||||
// `scripts/routeManifest.js`'s `mountPath` so the manifest and the spec can
|
||||
// never disagree about what a mount decodes to.
|
||||
//
|
||||
// This is core's own slot fill only. A MODULE ships a prebuilt
|
||||
// `swagger-fragment.json` in its bundle and core merges it at request time
|
||||
// (§6.1a) — core never has a module's sources to analyse.
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||
|
||||
const { mergeFragment, prefixPaths } = require('./mergeSpec')
|
||||
const { mountPath } = require('../scripts/routeManifest')
|
||||
|
||||
const SERVER_ROOT = path.join(__dirname, '..')
|
||||
|
||||
/**
|
||||
* Find `target` in an express stack and return the path prefix it is mounted at.
|
||||
*
|
||||
* Depth-first, accumulating each enclosing mount. Returns null when the router is
|
||||
* not on the stack at all — which for a filled slot means core declared it and
|
||||
* never mounted it, a bug worth failing the build over rather than papering over
|
||||
* with an unprefixed path.
|
||||
*/
|
||||
function findMountPrefix(stack, target, prefix = '') {
|
||||
for (const layer of stack || []) {
|
||||
if (!layer.handle || !Array.isArray(layer.handle.stack)) continue
|
||||
const here = prefix + mountPath(layer)
|
||||
if (layer.handle === target) return here
|
||||
const found = findMountPrefix(layer.handle.stack, target, here)
|
||||
if (found !== null) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one fragment by running swagger-autogen over a single router file.
|
||||
*
|
||||
* Its paths come out relative to that router (`/shard/accounts`) because nothing
|
||||
* in the file says where it hangs; `prefixPaths` supplies the rest.
|
||||
*/
|
||||
async function fragmentFor(specFile) {
|
||||
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rg-swagger-')), 'fragment.json')
|
||||
await swaggerAutogen(out, [path.relative(SERVER_ROOT, specFile).split(path.sep).join('/')], {
|
||||
info: { title: 'slot fragment', version: '0' },
|
||||
})
|
||||
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
||||
fs.rmSync(path.dirname(out), { recursive: true, force: true })
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge every filled core slot's routes into the generated spec file, in place.
|
||||
*
|
||||
* @param {string} outputFile the swagger-output.json swagger.js just wrote
|
||||
* @returns {Promise<number>} how many paths were added
|
||||
*/
|
||||
async function mergeSlotSpecs(outputFile) {
|
||||
/* eslint-disable global-require */
|
||||
const app = require('../src/app') // builds the app: declares and fills the slots
|
||||
const registries = require('../src/modules/registries')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const filled = registries.filledSlots().filter((s) => s.specFile)
|
||||
if (filled.length === 0) return 0
|
||||
|
||||
const spec = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
|
||||
let added = 0
|
||||
|
||||
for (const slot of filled) {
|
||||
const prefix = findMountPrefix(app._router.stack, slot.router)
|
||||
if (prefix === null) {
|
||||
throw new Error(
|
||||
`swagger: extension slot "${slot.slot}" is filled but its router is not mounted on the app — ` +
|
||||
'declareSlot() returned a router nobody use()d.',
|
||||
)
|
||||
}
|
||||
const fragment = prefixPaths(await fragmentFor(slot.specFile), prefix)
|
||||
const paths = Object.keys(fragment.paths || {}).length
|
||||
if (paths === 0) {
|
||||
throw new Error(
|
||||
`swagger: extension slot "${slot.slot}" generated an EMPTY fragment from ${slot.specFile}. ` +
|
||||
'That is the silent-drop failure this step exists to catch, not a slot with no routes.',
|
||||
)
|
||||
}
|
||||
mergeFragment(spec, fragment, `slot ${slot.slot}`)
|
||||
added += paths
|
||||
process.stdout.write(`merged ${paths} path(s) from slot ${slot.slot} at ${prefix}\n`)
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputFile, `${JSON.stringify(spec, null, 2)}\n`)
|
||||
return added
|
||||
}
|
||||
|
||||
module.exports = { mergeSlotSpecs, findMountPrefix }
|
||||
@@ -3249,7 +3249,7 @@
|
||||
"tags": [
|
||||
"Admin · Posts"
|
||||
],
|
||||
"summary": "Retry one announcement delivery leg (town crier or Discord)",
|
||||
"summary": "Retry one announcement delivery leg",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -3308,10 +3308,7 @@
|
||||
"properties": {
|
||||
"leg": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"towncrier",
|
||||
"discord"
|
||||
]
|
||||
"description": "A registered delivery leg id, as returned by GET /announce."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -11201,6 +11198,367 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
|
||||
@@ -1513,9 +1513,31 @@ function normalizePaths(spec) {
|
||||
return spec
|
||||
}
|
||||
|
||||
swaggerAutogen(outputFile, routes, doc).then(() => {
|
||||
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
|
||||
fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('swagger-output.json generated.')
|
||||
})
|
||||
// Static analysis cannot follow a route into an extension slot, so the slot
|
||||
// routers contribute a generated fragment afterwards — see swagger/slotSpecs.js
|
||||
// for what goes wrong without it. Merged BEFORE normalizePaths, so the merged-in
|
||||
// paths are sorted and trailing-slash-checked with everything else.
|
||||
//
|
||||
// The pool is pointed at a closed port here for the same reason
|
||||
// scripts/routeManifest.js does it: the merge step requires src/app.js to find
|
||||
// where each slot router is mounted, and requiring app.js builds the models. No
|
||||
// query is ever run.
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
/* eslint-disable global-require */
|
||||
swaggerAutogen(outputFile, routes, doc)
|
||||
.then(() => require('./slotSpecs').mergeSlotSpecs(outputFile))
|
||||
.then(() => {
|
||||
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
|
||||
fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('swagger-output.json generated.')
|
||||
// The mariadb pool keeps the loop alive even pointed at a dead port.
|
||||
return require('../src/utils/db').close()
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(`${err.stack || err.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -14,7 +14,14 @@ async function startApp(configure) {
|
||||
const { port } = server.address()
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
// `server.close()` stops accepting and waits for open connections to end on
|
||||
// their own — and node's global fetch keeps its sockets alive, so nothing
|
||||
// ever ends them. The listener then outlives the test that made it, which
|
||||
// used to be invisible because the pool held the process open anyway.
|
||||
close: () => new Promise((resolve) => {
|
||||
server.closeAllConnections()
|
||||
server.close(resolve)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
server/test/_setup.js
Normal file
42
server/test/_setup.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ── Test-process setup, loaded into every test file ────────────────────────
|
||||
//
|
||||
// `npm test` passes this with `--require`, so it runs before the test file it
|
||||
// is hosting — which is the only moment early enough to matter, because
|
||||
// `utils/db.js` builds its mariadb pool at REQUIRE time.
|
||||
//
|
||||
// It fixes two things that were per-file conventions, and therefore held only
|
||||
// as well as the next test file remembered them:
|
||||
//
|
||||
// 1. **Nothing in the suite may reach a real database.** Without this, a file
|
||||
// that forgot the two `process.env` lines picked up `server/.env` through
|
||||
// db.js's own `dotenv.config()` and pooled five live connections to the
|
||||
// developer's MariaDB. The tests still passed — they stub their models —
|
||||
// so the only symptom was the process never exiting, plus five connections
|
||||
// held for as long as the worker lived. Thirty stranded workers is 150
|
||||
// connections, which is the whole server's limit.
|
||||
// 2. **The pool is closed when the file's tests are done**, so the process can
|
||||
// exit at once instead of waiting out the driver's connect retries.
|
||||
//
|
||||
// `dotenv` does not overwrite variables that already exist, so pinning the dead
|
||||
// port here beats `.env` while still letting an explicit `DB_PORT=… npm test`
|
||||
// through for anyone who deliberately wants a live database.
|
||||
const { after } = require('node:test')
|
||||
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
// A root-level hook, registered before the test file is even read, so it runs
|
||||
// once after everything in that file.
|
||||
//
|
||||
// Only in the per-file child processes. `--require` is inherited by the runner
|
||||
// process too, and registering a root hook there gives node:test a second root
|
||||
// context to report — an empty "tests 0 / pass 0" summary printed after the real
|
||||
// one, which reads like a suite that silently ran nothing.
|
||||
if (process.env.NODE_TEST_CONTEXT) {
|
||||
after(async () => {
|
||||
// Resolved rather than required: a file that never touched the database must
|
||||
// not have a pool built for it here just so this can close one.
|
||||
const cached = require.cache[require.resolve('../src/utils/db')]
|
||||
if (cached) await cached.exports.close()
|
||||
})
|
||||
}
|
||||
@@ -75,7 +75,7 @@ test('salesForAccounts keeps only sales owned by the given accounts, newest 50',
|
||||
})
|
||||
|
||||
// ── Controller: unknown user → 404 ─────────────────────────────────────────
|
||||
for (const handler of ['getUser', 'listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||||
for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||||
test(`${handler} returns 404 when the user does not exist`, async () => {
|
||||
users.getById = async () => null
|
||||
const res = mockRes()
|
||||
@@ -144,10 +144,7 @@ test('a user with no linked accounts yields empty sales/houses/online', async ()
|
||||
assert.deepEqual(online.body, [])
|
||||
})
|
||||
|
||||
test('getUser returns the sanitized user row', async () => {
|
||||
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.username, 'bob')
|
||||
})
|
||||
// getUser is NOT here any more: reading a user is core semantics that had ended
|
||||
// up in this controller by proximity, and PR 4 moved it back to
|
||||
// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
|
||||
// covered by test/adminUsers.test.js.
|
||||
|
||||
65
server/test/adminUsers.test.js
Normal file
65
server/test/adminUsers.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ── Admin · Users: the core half of /admin/users/:id ───────────────────────
|
||||
//
|
||||
// `getUser` lived in usersShard.controller.js until Phase 2 PR 4, purely because
|
||||
// the detail page it backs is mostly shard panels. MODULE_SYSTEM.md §1.9 called
|
||||
// that out as core semantics that ended up in the UO controller by proximity, and
|
||||
// it moved back to admin.controller.js — the shard panels around it are now an
|
||||
// extension slot, so this handler has to stand on its own when they leave.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||||
// so any stray query fails fast instead of hanging the runner.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const ctrl = require('../src/router/v1/admin/admin.controller')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) {
|
||||
this.statusCode = c
|
||||
return this
|
||||
},
|
||||
json(b) {
|
||||
this.body = b
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const originalGetById = users.getById
|
||||
afterEach(() => {
|
||||
users.getById = originalGetById
|
||||
})
|
||||
|
||||
test('getUser returns the sanitized user row', async () => {
|
||||
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.username, 'bob')
|
||||
})
|
||||
|
||||
test('getUser returns 404 when the user does not exist', async () => {
|
||||
users.getById = async () => null
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '404' } }, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
test('getUser 500s rather than throwing when the model fails', async () => {
|
||||
users.getById = async () => {
|
||||
throw new Error('pool down')
|
||||
}
|
||||
const res = mockRes()
|
||||
await ctrl.getUser({ params: { id: '7' } }, res)
|
||||
assert.equal(res.statusCode, 500)
|
||||
})
|
||||
@@ -2,10 +2,14 @@ const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const logic = require('../src/model/announceJobs/announceJobs.logic')
|
||||
// Each leg owns its own text-building and result classification since PR 4 — a
|
||||
// leg is a registration now, not a branch in the worker (MODULE_SYSTEM.md §1.8).
|
||||
const townCrier = require('../src/utils/shardAnnounce')
|
||||
const discord = require('../src/utils/discordAnnounce')
|
||||
|
||||
// ── buildTownCrierText ───────────────────────────────────────────────────────
|
||||
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -13,7 +17,7 @@ test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
})
|
||||
|
||||
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -22,17 +26,17 @@ test('buildTownCrierText falls back to a stripped body when excerpt is empty', (
|
||||
|
||||
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
|
||||
const longTitle = 'x'.repeat(500)
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
for (const line of lines) assert.ok(line.length <= logic.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
assert.ok(lines[0].endsWith('…'))
|
||||
assert.ok(lines.length <= logic.MAX_LINES)
|
||||
assert.ok(lines.length <= townCrier.MAX_LINES)
|
||||
})
|
||||
|
||||
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
|
||||
const lines = logic.buildTownCrierText(
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'Only a title', excerpt: null, body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
@@ -40,27 +44,27 @@ test('buildTownCrierText omits the excerpt line when there is no excerpt or body
|
||||
})
|
||||
|
||||
// ── classifyTownCrier ────────────────────────────────────────────────────────
|
||||
test('classifyTownCrier: 2xx is done', () => {
|
||||
assert.equal(logic.classifyTownCrier({ ok: true, status: 200 }).outcome, 'done')
|
||||
test('town crier classify: 2xx is done', () => {
|
||||
assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
})
|
||||
|
||||
test('classifyTownCrier: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
for (const status of [400, 401, 409]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyTownCrier: shard-transient and network errors retry', () => {
|
||||
test('town crier classify: shard-transient and network errors retry', () => {
|
||||
for (const status of [503, 504, 500, 0]) {
|
||||
assert.equal(logic.classifyTownCrier({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── classifyDiscord ──────────────────────────────────────────────────────────
|
||||
test('classifyDiscord: ok is done, every failure retries', () => {
|
||||
assert.equal(logic.classifyDiscord({ ok: true, status: 200 }).outcome, 'done')
|
||||
test('discord classify: ok is done, every failure retries', () => {
|
||||
assert.equal(discord.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
for (const status of [400, 503, 0]) {
|
||||
assert.equal(logic.classifyDiscord({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
assert.equal(discord.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -75,12 +79,27 @@ test('scheduleAfter returns increasing delays then null at the attempt cap', ()
|
||||
})
|
||||
|
||||
// ── rollupStatus ─────────────────────────────────────────────────────────────
|
||||
test('rollupStatus derives the parent status from the two legs', () => {
|
||||
assert.equal(logic.rollupStatus('done', 'done'), 'done')
|
||||
assert.equal(logic.rollupStatus('failed', 'failed'), 'failed')
|
||||
assert.equal(logic.rollupStatus('pending', 'pending'), 'pending')
|
||||
// One terminal, the other not matching → partial.
|
||||
assert.equal(logic.rollupStatus('done', 'pending'), 'partial')
|
||||
assert.equal(logic.rollupStatus('pending', 'failed'), 'partial')
|
||||
assert.equal(logic.rollupStatus('done', 'failed'), 'partial')
|
||||
// Takes the LIST of leg statuses, not two named legs: which legs exist is what a
|
||||
// module decides (MODULE_SYSTEM.md §1.8).
|
||||
test('rollupStatus derives the parent status from its legs', () => {
|
||||
assert.equal(logic.rollupStatus(['done', 'done']), 'done')
|
||||
assert.equal(logic.rollupStatus(['failed', 'failed']), 'failed')
|
||||
assert.equal(logic.rollupStatus(['pending', 'pending']), 'pending')
|
||||
// One terminal, the others not matching → partial.
|
||||
assert.equal(logic.rollupStatus(['done', 'pending']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['pending', 'failed']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['done', 'failed']), 'partial')
|
||||
})
|
||||
|
||||
test('rollupStatus generalises past two legs', () => {
|
||||
assert.equal(logic.rollupStatus(['done', 'done', 'done']), 'done')
|
||||
assert.equal(logic.rollupStatus(['done', 'done', 'pending']), 'partial')
|
||||
assert.equal(logic.rollupStatus(['pending', 'pending', 'pending']), 'pending')
|
||||
assert.equal(logic.rollupStatus(['failed', 'failed', 'failed']), 'failed')
|
||||
// A single leg is not a special case.
|
||||
assert.equal(logic.rollupStatus(['pending']), 'pending')
|
||||
assert.equal(logic.rollupStatus(['done']), 'done')
|
||||
// No legs registered at all: nothing is left to deliver, so the job is done
|
||||
// rather than pending forever on a leg that does not exist.
|
||||
assert.equal(logic.rollupStatus([]), 'done')
|
||||
})
|
||||
|
||||
206
server/test/announceLegs.test.js
Normal file
206
server/test/announceLegs.test.js
Normal file
@@ -0,0 +1,206 @@
|
||||
// ── The announcement pipeline, once legs became registrations ──────────────
|
||||
//
|
||||
// Phase 2 PR 4 (docs/website/MODULE_SYSTEM.md §1.8): `announce_jobs`' two
|
||||
// hardcoded leg column groups became `announce_job_legs` rows, and which legs
|
||||
// exist is what modules/registries.js answers. These tests are about that
|
||||
// property specifically — that nothing in the worker or the model knows the word
|
||||
// "towncrier", and a leg nobody registered is handled rather than assumed away.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything; every DB call the
|
||||
// model makes is stubbed.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const announceJobs = require('../src/model/announceJobs/announceJobs.model')
|
||||
const announceDb = require('../src/model/announceJobs/announceJobs.db')
|
||||
const worker = require('../src/utils/announceWorker')
|
||||
const posts = require('../src/model/posts/posts.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const originals = {
|
||||
create: announceDb.create,
|
||||
findById: announceDb.findById,
|
||||
findByPostId: announceDb.findByPostId,
|
||||
findDue: announceDb.findDue,
|
||||
updateLeg: announceDb.updateLeg,
|
||||
ensureLegs: announceDb.ensureLegs,
|
||||
setStatus: announceDb.setStatus,
|
||||
linkAnnounceJob: posts.linkAnnounceJob,
|
||||
markAnnounced: posts.markAnnounced,
|
||||
getById: posts.getById,
|
||||
}
|
||||
afterEach(() => Object.assign(announceDb, {
|
||||
create: originals.create,
|
||||
findById: originals.findById,
|
||||
findByPostId: originals.findByPostId,
|
||||
findDue: originals.findDue,
|
||||
updateLeg: originals.updateLeg,
|
||||
ensureLegs: originals.ensureLegs,
|
||||
setStatus: originals.setStatus,
|
||||
}) && Object.assign(posts, {
|
||||
linkAnnounceJob: originals.linkAnnounceJob,
|
||||
markAnnounced: originals.markAnnounced,
|
||||
getById: originals.getById,
|
||||
}))
|
||||
|
||||
// Two legs that record what they were asked to do, standing in for core's
|
||||
// discord and a module's own.
|
||||
function fakeLegs() {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
a: { leg: 'discord', label: 'Discord #news', dispatch: async (p) => { calls.push(['discord', p.id]); return { ok: true } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'x' }) },
|
||||
b: { leg: 'rust.motd', label: 'Server MOTD', dispatch: async (p) => { calls.push(['rust.motd', p.id]); return { ok: false, status: 503 } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'down' }) },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
|
||||
function register(...legs) {
|
||||
const api = registries.stage('core')
|
||||
for (const l of legs) api.registerAnnounceLeg(l)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
// ── Enqueue ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('enqueue creates one leg row per REGISTERED leg, whatever they are', () => {
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
|
||||
let created = null
|
||||
announceDb.create = async (postId, ids) => { created = { postId, ids }; return 42 }
|
||||
posts.linkAnnounceJob = async () => {}
|
||||
|
||||
return announceJobs.enqueue(9).then((jobId) => {
|
||||
assert.equal(jobId, 42)
|
||||
assert.deepEqual(created, { postId: 9, ids: ['discord', 'rust.motd'] })
|
||||
})
|
||||
})
|
||||
|
||||
test('with no legs registered, a job is created with none — and rolls up done', async () => {
|
||||
let created = null
|
||||
announceDb.create = async (postId, ids) => { created = { postId, ids }; return 1 }
|
||||
posts.linkAnnounceJob = async () => {}
|
||||
await announceJobs.enqueue(9)
|
||||
assert.deepEqual(created.ids, [])
|
||||
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 9, status: 'pending', legs: [] })
|
||||
const statuses = []
|
||||
announceDb.setStatus = async (_id, s) => statuses.push(s)
|
||||
posts.markAnnounced = async () => {}
|
||||
const job = await announceJobs.refreshStatus(1)
|
||||
assert.equal(job.status, 'done')
|
||||
assert.deepEqual(statuses, ['done'])
|
||||
})
|
||||
|
||||
// ── The worker dispatches through the registration ─────────────────────────
|
||||
|
||||
test('the worker dispatches each due leg through whatever registered it', async () => {
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
posts.getById = async () => ({ id: 5, title: 't', excerpt: 'e', body: null })
|
||||
|
||||
const updates = []
|
||||
announceDb.findDue = async () => [{
|
||||
id: 1,
|
||||
post_id: 5,
|
||||
status: 'pending',
|
||||
legs: [
|
||||
{ leg: 'discord', status: 'pending', attempts: 0, next_attempt_at: null },
|
||||
{ leg: 'rust.motd', status: 'pending', attempts: 0, next_attempt_at: null },
|
||||
],
|
||||
}]
|
||||
announceDb.updateLeg = async (jobId, leg, fields) => updates.push([leg, fields.status])
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [] })
|
||||
announceDb.setStatus = async () => {}
|
||||
posts.markAnnounced = async () => {}
|
||||
|
||||
await worker.tick(new Date())
|
||||
|
||||
assert.deepEqual(legs.calls, [['discord', 5], ['rust.motd', 5]])
|
||||
// discord delivered; rust.motd got a 503, so it is rescheduled, not failed.
|
||||
assert.deepEqual(updates, [['discord', 'done'], ['rust.motd', 'pending']])
|
||||
})
|
||||
|
||||
test('a leg row nobody registers any more is left alone, not failed', async () => {
|
||||
// Its module was uninstalled. Failing it would roll the job up terminal on the
|
||||
// strength of a leg that no longer exists, and reinstalling should resume it.
|
||||
register(fakeLegs().a)
|
||||
let touched = false
|
||||
announceDb.updateLeg = async () => { touched = true }
|
||||
posts.getById = async () => { throw new Error('must not even look up the post') }
|
||||
|
||||
await worker.processLeg({ id: 1, post_id: 5, legs: [{ leg: 'gone.leg', status: 'pending', attempts: 0 }] }, 'gone.leg')
|
||||
assert.equal(touched, false)
|
||||
})
|
||||
|
||||
test('isLegDue reads the row, not a leg-prefixed column', () => {
|
||||
const past = new Date(Date.now() - 1000)
|
||||
const future = new Date(Date.now() + 60_000)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: null }, new Date()), true)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: past }, new Date()), true)
|
||||
assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: future }, new Date()), false)
|
||||
assert.equal(worker.isLegDue({ status: 'done', next_attempt_at: null }, new Date()), false)
|
||||
assert.equal(worker.isLegDue({ status: 'failed', next_attempt_at: null }, new Date()), false)
|
||||
})
|
||||
|
||||
// ── Retry, and the label the panel renders ─────────────────────────────────
|
||||
|
||||
test('resetLeg refuses a leg nobody registered', async () => {
|
||||
register(fakeLegs().a)
|
||||
await assert.rejects(() => announceJobs.resetLeg(5, 'rust.motd'), /unknown announce leg/)
|
||||
})
|
||||
|
||||
test('resetLeg creates the row when a module was installed after the job', async () => {
|
||||
// Otherwise the retry button could never deliver a newly-installed module's
|
||||
// leg on an already-announced post: the worker only sees rows that exist.
|
||||
const legs = fakeLegs()
|
||||
register(legs.a, legs.b)
|
||||
const ensured = []
|
||||
announceDb.findByPostId = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }] })
|
||||
announceDb.ensureLegs = async (jobId, ids) => ensured.push([jobId, ids])
|
||||
announceDb.updateLeg = async () => {}
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }, { leg: 'rust.motd', status: 'pending' }] })
|
||||
announceDb.setStatus = async () => {}
|
||||
|
||||
const job = await announceJobs.resetLeg(5, 'rust.motd')
|
||||
assert.deepEqual(ensured, [[1, ['rust.motd']]])
|
||||
assert.deepEqual(job.legs.map((l) => l.label), ['Discord #news', 'Server MOTD'])
|
||||
})
|
||||
|
||||
test('a leg’s label comes from its registration, and an orphan keeps its id', async () => {
|
||||
register(fakeLegs().a)
|
||||
announceDb.findByPostId = async () => ({
|
||||
id: 1,
|
||||
post_id: 5,
|
||||
status: 'partial',
|
||||
legs: [{ leg: 'discord', status: 'done' }, { leg: 'gone.leg', status: 'pending' }],
|
||||
})
|
||||
const job = await announceJobs.getByPostId(5)
|
||||
assert.deepEqual(job.legs.map((l) => [l.leg, l.label]), [
|
||||
['discord', 'Discord #news'],
|
||||
['gone.leg', 'gone.leg'],
|
||||
])
|
||||
})
|
||||
|
||||
// ── recordOutcome reads attempts off the row ───────────────────────────────
|
||||
|
||||
test('recordOutcome takes the attempt count from the leg row', async () => {
|
||||
register(fakeLegs().a)
|
||||
const updates = []
|
||||
announceDb.updateLeg = async (jobId, leg, fields) => updates.push(fields)
|
||||
announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [{ leg: 'discord', status: 'pending' }] })
|
||||
announceDb.setStatus = async () => {}
|
||||
|
||||
const job = { id: 1, post_id: 5, legs: [{ leg: 'discord', status: 'pending', attempts: 2 }] }
|
||||
await announceJobs.recordOutcome(job, 'discord', { outcome: 'retry', error: 'nope' })
|
||||
assert.equal(updates[0].attempts, 3)
|
||||
assert.ok(updates[0].nextAttemptAt instanceof Date)
|
||||
})
|
||||
@@ -23,21 +23,11 @@ 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.
|
||||
// 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 pub = require('../src/router/v1/public/atlas.controller')
|
||||
const admin = require('../src/router/v1/admin/shardAtlas.controller')
|
||||
const atlas = require('../../modules/uo/server/model/shardAtlas/shardAtlas.model')
|
||||
const atlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const visibility = require('../../modules/uo/server/utils/visibility')
|
||||
const visibility = require('../src/utils/shardVisibility')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -46,7 +36,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('../../modules/uo/server/model/shardVisibility/shardVisibility.model')
|
||||
const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model')
|
||||
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
|
||||
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
|
||||
|
||||
|
||||
376
server/test/moduleLifecycle.test.js
Normal file
376
server/test/moduleLifecycle.test.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// ── Boot and shutdown dispatch ─────────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 5. The contract is MODULE_API.md §2.5 (when the hooks run, in what
|
||||
// order, with what budget), §4.4 (a failure after mounting is a 503, not a
|
||||
// crash), §4.5 (a disabled module is guarded, never unmounted) and
|
||||
// MODULE_SYSTEM.md §2.4 (what a boot does to installed_modules).
|
||||
//
|
||||
// The property under test throughout, as in moduleLoader.test.js and
|
||||
// moduleSchema.test.js: **the failing module fails alone.** A hook that throws,
|
||||
// a hook that hangs, a row that will not write — none of them may cost the site
|
||||
// its boot or the next module its start.
|
||||
//
|
||||
// No database is involved: `boot()` takes the model as an injectable dependency
|
||||
// for exactly the reason `replayFragments` takes its query, and the fake below
|
||||
// records every call so the ORDER of the reconcile can be asserted — which is
|
||||
// the whole design, not an implementation detail.
|
||||
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 express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const lifecycle = require('../src/modules/lifecycle')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let tmpRoot
|
||||
|
||||
const emptyTiers = () => ({
|
||||
public: express.Router(),
|
||||
admin: express.Router(),
|
||||
player: express.Router(),
|
||||
})
|
||||
|
||||
function freshLoader(dir, tiers = emptyTiers()) {
|
||||
process.env.MODULES_DIR = dir
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(tiers)
|
||||
return loader
|
||||
}
|
||||
|
||||
/**
|
||||
* A module whose hooks report themselves into a file.
|
||||
*
|
||||
* A file rather than a shared array because the module is `require`d from disk
|
||||
* and cannot close over anything this file owns — the same trick the ctx probe
|
||||
* in moduleLoader.test.js uses.
|
||||
*/
|
||||
function writeModule(id, { boot, shutdown, mounts, log: logFile } = {}) {
|
||||
const dir = path.join(tmpRoot, id)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
||||
id,
|
||||
name: `Module ${id}`,
|
||||
version: '1.2.3',
|
||||
coreApi: '^1.0.0',
|
||||
server: 'index.js',
|
||||
...(mounts === undefined ? {} : { mounts }),
|
||||
}))
|
||||
const note = logFile
|
||||
? `const note = (what) => require('fs').appendFileSync(${JSON.stringify(logFile)}, what + '\\n')`
|
||||
: 'const note = () => {}'
|
||||
const register = mounts === undefined ? '' : `
|
||||
const r = ctx.express.Router()
|
||||
r.get('/', (req, res) => res.json({ ok: true }))
|
||||
api.registerRoutes({ public: { '${(mounts.public || [])[0]}': r } })`
|
||||
// `boot: ''` means "registers a hook that does nothing", which is a different
|
||||
// module from one that registers no hook at all — hence the undefined check
|
||||
// rather than a truthiness test.
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), `${note}
|
||||
module.exports = (ctx, api) => {${register}
|
||||
${boot === undefined ? '' : `api.onBoot(async (c) => { note('boot:${id}' + (c && c.moduleId === '${id}' ? ':ctx' : ':NOCTX')); ${boot} })`}
|
||||
${shutdown === undefined ? '' : `api.onShutdown(async () => { note('shutdown:${id}'); ${shutdown} })`}
|
||||
}`)
|
||||
return dir
|
||||
}
|
||||
|
||||
/** An in-memory stand-in for model/modules/modules.model.js. */
|
||||
function fakeModel(seed = []) {
|
||||
const rows = new Map(seed.map((r) => [r.id, { failureStage: null, failureReason: null, ...r }]))
|
||||
const calls = []
|
||||
const model = {
|
||||
rows,
|
||||
calls,
|
||||
async beginBoot() {
|
||||
calls.push('beginBoot')
|
||||
for (const row of rows.values()) {
|
||||
if (row.state === 'disabled') continue
|
||||
Object.assign(row, { state: 'enabled', failureStage: null, failureReason: null })
|
||||
}
|
||||
},
|
||||
async recordInstalled({ id, name, version }) {
|
||||
calls.push(`recordInstalled:${id}`)
|
||||
const row = rows.get(id)
|
||||
// Metadata is refreshed; state is deliberately left alone (§2.4).
|
||||
if (row) Object.assign(row, { name, version })
|
||||
else rows.set(id, { id, name, version, state: 'installed', failureStage: null, failureReason: null })
|
||||
},
|
||||
async list() {
|
||||
calls.push('list')
|
||||
return [...rows.values()]
|
||||
},
|
||||
async markStarted(id) {
|
||||
calls.push(`markStarted:${id}`)
|
||||
Object.assign(rows.get(id), { state: 'started', failureStage: null, failureReason: null })
|
||||
},
|
||||
async markStartupFailed(id, { stage, reason }) {
|
||||
calls.push(`markStartupFailed:${id}`)
|
||||
const row = rows.get(id)
|
||||
// The model's own softening, reproduced because tests below depend on it:
|
||||
// a disabled row is a no-op, or an outcome would overwrite the operator's
|
||||
// decision and silently re-enable the module next boot.
|
||||
if (!row || row.state === 'disabled') return
|
||||
Object.assign(row, { state: 'startup_failed', failureStage: stage, failureReason: reason })
|
||||
},
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
|
||||
const noted = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n') : [])
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-lifecycle-'))
|
||||
})
|
||||
|
||||
// ── The happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
test('every module is recorded, booted with its ctx and marked started, in scan order', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
|
||||
// The reconcile order IS the design (§2.4): clear the last boot's outcomes
|
||||
// first, so what is on display afterwards is what this boot did.
|
||||
assert.equal(model.calls[0], 'beginBoot')
|
||||
assert.deepEqual(model.calls.slice(1, 3), ['recordInstalled:aaa', 'recordInstalled:bbb'])
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'started')
|
||||
assert.equal(model.rows.get('bbb').state, 'started')
|
||||
// §2.4's metadata refresh: the row carries what the admin screen shows.
|
||||
assert.equal(model.rows.get('aaa').name, 'Module aaa')
|
||||
assert.equal(model.rows.get('aaa').version, '1.2.3')
|
||||
})
|
||||
|
||||
test('a hand-placed directory gets a row with no provenance', async () => {
|
||||
// §2.5 keeps a directory dropped on the volume by hand a supported install.
|
||||
// Without a row it could never be disabled, and nothing could report it.
|
||||
writeModule('byhand', { boot: '' })
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: freshLoader(tmpRoot), model })
|
||||
|
||||
const row = model.rows.get('byhand')
|
||||
assert.equal(row.state, 'started')
|
||||
assert.equal(row.source ?? null, null)
|
||||
assert.equal(row.sha256 ?? null, null)
|
||||
})
|
||||
|
||||
test('a module with no onBoot still reaches started', async () => {
|
||||
writeModule('quiet', {})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
|
||||
// Nothing to warm up is not the same as never having started: the guard lets
|
||||
// its routes through, so the row has to agree that it is serving.
|
||||
assert.equal(stateOf(loader, 'quiet').state, 'started')
|
||||
assert.equal(model.rows.get('quiet').state, 'started')
|
||||
})
|
||||
|
||||
// ── Failure is a state ─────────────────────────────────────────────────────
|
||||
|
||||
test('an onBoot that throws fails its own module and no one else', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
writeModule('bbb', { boot: 'throw new Error("cache warm-up failed")', log: file })
|
||||
writeModule('ccc', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
|
||||
assert.equal(stateOf(loader, 'bbb').stage, 'boot')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /cache warm-up failed/)
|
||||
assert.equal(model.rows.get('bbb').failureStage, 'boot')
|
||||
// The one that matters: the module AFTER the failure still booted.
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx', 'boot:bbb:ctx', 'boot:ccc:ctx'])
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'started')
|
||||
})
|
||||
|
||||
test('a module whose onBoot failed keeps its URLs and answers 503', async () => {
|
||||
// §4.4's right-hand column, reached by the real mechanism rather than a
|
||||
// hand-moved state: routes.manifest.json must not depend on whether a boot
|
||||
// hook happened to succeed on the machine that generated it.
|
||||
writeModule('svc', { boot: 'throw new Error("no")', mounts: { public: ['/widgets'] } })
|
||||
const tiers = emptyTiers()
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a failure from load or schema replay is written down with its stage', async () => {
|
||||
// Both happen before the database is reachable — load() at require time, the
|
||||
// replay inside ensureSchema — so the boot reconcile is where they land.
|
||||
writeModule('bad', {})
|
||||
fs.writeFileSync(path.join(tmpRoot, 'bad', 'module.json'), JSON.stringify({
|
||||
id: 'bad', name: 'bad', version: '1.0.0', coreApi: '^99.0.0',
|
||||
}))
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
|
||||
const row = model.rows.get('bad')
|
||||
assert.equal(row.state, 'startup_failed')
|
||||
assert.equal(row.failureStage, 'core_api')
|
||||
assert.match(row.failureReason, /needs core API \^99\.0\.0/)
|
||||
})
|
||||
|
||||
// ── The operator's switch ──────────────────────────────────────────────────
|
||||
|
||||
test('a disabled row guards the module, skips its hook and is not overwritten', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('off', { boot: '', shutdown: '', mounts: { public: ['/widgets'] }, log: file })
|
||||
const tiers = emptyTiers()
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
const model = fakeModel([{ id: 'off', name: 'Module off', version: '1.2.3', state: 'disabled' }])
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
|
||||
try {
|
||||
await lifecycle.boot({ modules: loader, model })
|
||||
|
||||
// §4.5's 404 leg, unreachable until this reconcile existed: mounted and
|
||||
// guarded, never unmounted, so the URL surface stays a property of the
|
||||
// volume rather than of a database row.
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404)
|
||||
assert.equal(stateOf(loader, 'off').state, 'disabled')
|
||||
assert.deepEqual(noted(file), [], 'a disabled module must not be booted')
|
||||
// Still disabled: an outcome must never overwrite a decision, or the next
|
||||
// boot would silently switch it back on.
|
||||
assert.equal(model.rows.get('off').state, 'disabled')
|
||||
// And nothing to tear down, because it never started.
|
||||
await lifecycle.shutdown({ modules: loader })
|
||||
assert.deepEqual(noted(file), [])
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a row whose directory is gone is marked failed rather than left claiming enabled', async () => {
|
||||
writeModule('here', { boot: '' })
|
||||
const model = fakeModel([
|
||||
{ id: 'here', name: 'Module here', version: '1.2.3', state: 'started' },
|
||||
{ id: 'gone', name: 'Module gone', version: '0.9.0', state: 'started' },
|
||||
// An uninstall leaves `disabled`, which beginBoot never touches — so this
|
||||
// one is not an anomaly and must be left exactly as the operator left it.
|
||||
{ id: 'uninstalled', name: 'Module uninstalled', version: '0.1.0', state: 'disabled' },
|
||||
])
|
||||
|
||||
await lifecycle.boot({ modules: freshLoader(tmpRoot), model })
|
||||
|
||||
assert.equal(model.rows.get('here').state, 'started')
|
||||
assert.equal(model.rows.get('gone').state, 'startup_failed')
|
||||
assert.match(model.rows.get('gone').failureReason, /not present on the volume/)
|
||||
assert.equal(model.rows.get('uninstalled').state, 'disabled')
|
||||
})
|
||||
|
||||
// ── The site comes up regardless ───────────────────────────────────────────
|
||||
|
||||
test('a database that will not take the bookkeeping still boots the modules', async () => {
|
||||
// A row that will not update is bad — the admin panel shows the wrong thing —
|
||||
// but it is strictly less bad than a site that will not start.
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const model = fakeModel()
|
||||
for (const name of ['beginBoot', 'recordInstalled', 'list', 'markStarted']) {
|
||||
model[name] = async () => { throw new Error('ER_LOCK_WAIT_TIMEOUT') }
|
||||
}
|
||||
|
||||
await assert.doesNotReject(() => lifecycle.boot({ modules: loader, model }))
|
||||
|
||||
assert.deepEqual(noted(file), ['boot:aaa:ctx'])
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'started')
|
||||
})
|
||||
|
||||
test('a process that never scanned writes nothing at all', async () => {
|
||||
// `npm run seed` is exactly this: it calls ensureSchema() without ever
|
||||
// requiring app.js. Reconciling against an empty scan would mark every
|
||||
// installed module as missing from the volume.
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const unscanned = require('../src/modules/loader')
|
||||
const model = fakeModel([{ id: 'real', name: 'Module real', version: '1.0.0', state: 'started' }])
|
||||
|
||||
await lifecycle.boot({ modules: unscanned, model })
|
||||
|
||||
assert.deepEqual(model.calls, [])
|
||||
assert.equal(model.rows.get('real').state, 'started')
|
||||
})
|
||||
|
||||
// ── Shutdown ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('shutdown runs started modules in reverse order and skips the rest', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('bbb', { boot: 'throw new Error("no")', shutdown: '', log: file })
|
||||
writeModule('ccc', { boot: '', shutdown: '', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
fs.writeFileSync(file, '') // only the shutdown half is under test
|
||||
await lifecycle.shutdown({ modules: loader })
|
||||
|
||||
// Reverse of boot order, and `bbb` absent: its onBoot threw, so it has a
|
||||
// half-built world that its onShutdown was never written to tear down.
|
||||
assert.deepEqual(noted(file), ['shutdown:ccc', 'shutdown:aaa'])
|
||||
})
|
||||
|
||||
test('a hook that hangs costs its budget, not the shutdown', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('zzz', { boot: '', shutdown: 'await new Promise(() => {})', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
fs.writeFileSync(file, '')
|
||||
|
||||
const started = Date.now()
|
||||
await lifecycle.shutdown({ modules: loader, budgetMs: 50 })
|
||||
|
||||
// zzz never returns; it is abandoned and aaa still gets its turn. The
|
||||
// alternative is a host where `systemctl stop` hangs until SIGKILL.
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
|
||||
assert.ok(Date.now() - started < 2000, 'shutdown must not wait on a hung hook')
|
||||
})
|
||||
|
||||
test('a hook that throws does not stop the ones behind it', async () => {
|
||||
const file = path.join(tmpRoot, 'log.txt')
|
||||
writeModule('aaa', { boot: '', shutdown: '', log: file })
|
||||
writeModule('zzz', { boot: '', shutdown: 'throw new Error("close failed")', log: file })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
await lifecycle.boot({ modules: loader, model: fakeModel() })
|
||||
fs.writeFileSync(file, '')
|
||||
|
||||
await assert.doesNotReject(() => lifecycle.shutdown({ modules: loader }))
|
||||
assert.deepEqual(noted(file), ['shutdown:zzz', 'shutdown:aaa'])
|
||||
})
|
||||
@@ -1,17 +1,3 @@
|
||||
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
|
||||
@@ -22,14 +8,58 @@ after(() => db.close())
|
||||
//
|
||||
// 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.
|
||||
// MODULES_DIR is read into a const at require time (it has to be: the scan is
|
||||
// synchronous and happens during app.js's require), so busting the cache is the
|
||||
// only honest way to point the loader somewhere else.
|
||||
//
|
||||
// Point the pool at a closed port BEFORE requiring anything: buildCtx pulls in
|
||||
// the models, which build a mariadb pool at require time. No query is ever run.
|
||||
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 express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
// Requiring the real admin router declares the `admin.users.detail` extension
|
||||
// slot exactly the way production does (users.router.js, at require time). Doing
|
||||
// it here rather than calling declareSlot by hand matters: one test below builds
|
||||
// the real tier routers, and a hand-declared slot would collide with that
|
||||
// require's own declaration.
|
||||
require('../src/router/v1/admin')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let tmpRoot
|
||||
|
||||
function freshLoader(dir) {
|
||||
/** Three empty routers standing in for core's tiers — nothing owned, nothing gated. */
|
||||
const emptyTiers = () => ({
|
||||
public: express.Router(),
|
||||
admin: express.Router(),
|
||||
player: express.Router(),
|
||||
})
|
||||
|
||||
function freshLoader(dir, tiers = emptyTiers()) {
|
||||
process.env.MODULES_DIR = dir
|
||||
// The registries are process-global (there is one core), so hand the process
|
||||
// back between tests. Without this a module's staged registrations from a
|
||||
// previous test would still be committed, and every collision assertion below
|
||||
// would be asserting against the wrong history.
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
return require('../src/modules/loader')
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(tiers)
|
||||
return loader
|
||||
}
|
||||
|
||||
function writeModule(id, { manifest = {}, server, schema } = {}) {
|
||||
@@ -53,17 +83,71 @@ function writeModule(id, { manifest = {}, server, schema } = {}) {
|
||||
return dir
|
||||
}
|
||||
|
||||
/** A module that registers one router answering 200 at its prefix root. */
|
||||
const oneRoute = (prefix, tier = 'public') => ({
|
||||
manifest: { mounts: { [tier]: [prefix] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
const r = ctx.express.Router()
|
||||
r.get('/', (req, res) => res.json({ ok: true }))
|
||||
api.registerRoutes({ ${tier}: { '${prefix}': r } })
|
||||
}`,
|
||||
})
|
||||
|
||||
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
})
|
||||
|
||||
// ── Discovery and the explicit trigger ─────────────────────────────────────
|
||||
|
||||
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('reading the module list before load() throws instead of answering []', () => {
|
||||
// §7.6. The spike's scan was lazy and silent, so a caller that required the
|
||||
// loader and read nothing got an empty list — indistinguishable from a core
|
||||
// with no modules installed. It cost one confusing failure; it now costs an
|
||||
// error naming the missing call.
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
assert.throws(() => loader.list(), /modules\.list\(\) before modules\.load\(\)/)
|
||||
})
|
||||
|
||||
test('load() refuses to run without all three tier routers', () => {
|
||||
writeModule('aaa', { server: 'module.exports = () => {}' })
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
// Not a module's failure — a wiring mistake in core, and the one thing in this
|
||||
// file that is allowed to throw past the caller.
|
||||
assert.throws(() => loader.load({ public: express.Router() }), /missing the "admin" tier router/)
|
||||
})
|
||||
|
||||
test('load() is once-only, so a second call cannot double-mount', () => {
|
||||
writeModule('aaa', oneRoute('/thing'))
|
||||
const tiers = emptyTiers()
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
const before = tiers.public.stack.length
|
||||
|
||||
loader.load(tiers)
|
||||
assert.equal(tiers.public.stack.length, before)
|
||||
assert.equal(loader.list().length, 1)
|
||||
})
|
||||
|
||||
test('modules load in alphabetical order, since nothing computes a precedence', () => {
|
||||
for (const id of ['ccc', 'aaa', 'bbb']) writeModule(id, { server: 'module.exports = () => {}' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc'])
|
||||
})
|
||||
|
||||
// ── The failing module fails alone ─────────────────────────────────────────
|
||||
|
||||
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")' })
|
||||
@@ -78,6 +162,12 @@ test('a module whose entry point throws does not stop the others loading', () =>
|
||||
assert.match(bad.reason, /boom/)
|
||||
})
|
||||
|
||||
test('an entry point that exports something other than a function is rejected', () => {
|
||||
writeModule('notfn', { server: 'module.exports = { register: () => {} }' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'notfn').reason, /does not export a function/)
|
||||
})
|
||||
|
||||
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', {
|
||||
@@ -106,25 +196,71 @@ test('a module id that does not match its directory is rejected', () => {
|
||||
assert.match(stateOf(loader, 'onedir').reason, /does not match directory/)
|
||||
})
|
||||
|
||||
test('an unknown extension slot is rejected; only core may declare a slot', () => {
|
||||
writeModule('presumptuous', { manifest: { extensions: ['admin.users.detail', 'admin.invented'] } })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'presumptuous').reason, /unknown extension slot "admin\.invented"/)
|
||||
})
|
||||
|
||||
// ── Prefix ownership ───────────────────────────────────────────────────────
|
||||
|
||||
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() } })",
|
||||
})
|
||||
writeModule('aaa', oneRoute('/thing'))
|
||||
writeModule('bbb', oneRoute('/thing'))
|
||||
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', () => {
|
||||
test('a module cannot take a prefix core owns, asked of the live tier routers', () => {
|
||||
// Deliberately the REAL routers rather than a hardcoded prefix list. The spike
|
||||
// hardcoded core's ~24 prefixes and they were already stale; asking express
|
||||
// itself is what stops the check drifting the next time core adds a capability
|
||||
// router.
|
||||
/* eslint-disable global-require */
|
||||
const real = {
|
||||
public: require('../src/router/v1/public'),
|
||||
admin: require('../src/router/v1/admin'),
|
||||
player: require('../src/router/v1/player'),
|
||||
}
|
||||
/* eslint-enable global-require */
|
||||
|
||||
writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
writeModule('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } })
|
||||
// Free in every tier core actually mounts, so it must be allowed through.
|
||||
writeModule('polite', oneRoute('/widgets', 'player'))
|
||||
|
||||
const loader = freshLoader(tmpRoot, real)
|
||||
assert.match(stateOf(loader, 'greedy').reason, /owned by core/)
|
||||
assert.match(stateOf(loader, 'alsogreedy').reason, /owned by core/)
|
||||
assert.equal(stateOf(loader, 'polite').state, 'registered')
|
||||
})
|
||||
|
||||
test('a root-mounted core layer does not make every prefix look taken', () => {
|
||||
// public/index.js ends with `use('/', siteRouter)` and admin with the dashboard
|
||||
// router; both match every path. Counting them would report every prefix as
|
||||
// owned and no module could ever mount.
|
||||
const tiers = emptyTiers()
|
||||
tiers.public.use('/posts', express.Router())
|
||||
tiers.public.use('/', express.Router())
|
||||
|
||||
writeModule('fine', oneRoute('/widgets'))
|
||||
writeModule('taken', oneRoute('/posts'))
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
||||
assert.match(stateOf(loader, 'taken').reason, /owned by core/)
|
||||
})
|
||||
|
||||
test('a prefix with a slash or a parameter in it is rejected', () => {
|
||||
// A prefix that could contain either would let a module reach outside the slot
|
||||
// it was given.
|
||||
writeModule('nested', { manifest: { mounts: { public: ['/a/b'] } } })
|
||||
writeModule('parameterised', { manifest: { mounts: { public: ['/:id'] } } })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'nested').reason, /bad prefix "\/a\/b"/)
|
||||
assert.match(stateOf(loader, 'parameterised').reason, /bad prefix "\/:id"/)
|
||||
})
|
||||
|
||||
test('registering a prefix that was never declared is rejected', () => {
|
||||
@@ -149,62 +285,6 @@ test('declaring a prefix and never registering it is rejected too', () => {
|
||||
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'] } },
|
||||
@@ -217,6 +297,194 @@ test('registering the same thing twice is an error, not a silent overwrite', ()
|
||||
assert.match(stateOf(loader, 'twice').reason, /registerRoutes\(\) called twice/)
|
||||
})
|
||||
|
||||
// ── Schema fragment validation (the replay itself is moduleSchema.test.js) ──
|
||||
|
||||
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);' })
|
||||
assert.match(stateOf(freshLoader(tmpRoot), 'mine').reason, /not prefixed "mine_"/)
|
||||
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS mine_widgets (id INT);' })
|
||||
assert.equal(stateOf(freshLoader(tmpRoot), 'mine').state, 'registered')
|
||||
})
|
||||
|
||||
test('two modules cannot own the same table either', () => {
|
||||
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);' })
|
||||
writeModule('bbb', {
|
||||
manifest: { id: 'bbb' },
|
||||
schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);',
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /already owned by module "aaa"/)
|
||||
})
|
||||
|
||||
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)
|
||||
// A module that can create tables and cannot drop them leaves an operator with
|
||||
// orphaned data and no supported way to remove it.
|
||||
assert.match(stateOf(loader, 'noway').reason, /declares schema but no purge/)
|
||||
})
|
||||
|
||||
test('a declared purge file that is not there is rejected', () => {
|
||||
writeModule('gone', { schema: 'CREATE TABLE IF NOT EXISTS gone_x (id INT);' })
|
||||
fs.unlinkSync(path.join(tmpRoot, 'gone', 'purge.sql'))
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.match(stateOf(loader, 'gone').reason, /purge file "purge\.sql" is missing/)
|
||||
})
|
||||
|
||||
test('a fragment may only use the four verbs core\'s own schema.sql uses', () => {
|
||||
// An allowlist rather than a DROP denylist. §2.6 bans DROP, but this file is
|
||||
// REPLAYED ON EVERY BOOT, so TRUNCATE and DELETE would empty a table at every
|
||||
// restart and RENAME would fail at the second one — a denylist only ever bans
|
||||
// what somebody thought of.
|
||||
for (const [id, sql, verb] of [
|
||||
['dropper', 'DROP TABLE dropper_x;', 'DROP'],
|
||||
['nuker', 'TRUNCATE TABLE nuker_x;', 'TRUNCATE'],
|
||||
['wiper', 'DELETE FROM wiper_x;', 'DELETE'],
|
||||
['granter', 'GRANT ALL ON *.* TO app;', 'GRANT'],
|
||||
]) {
|
||||
writeModule(id, { schema: sql })
|
||||
const reason = stateOf(freshLoader(tmpRoot), id).reason
|
||||
assert.match(reason, new RegExp(`starts with "${verb}"`))
|
||||
}
|
||||
})
|
||||
|
||||
test('a fragment may INSERT and UPDATE its own seed data', () => {
|
||||
// Core's schema.sql does both (INSERT IGNORE INTO settings, one UPDATE), so a
|
||||
// module that seeds a lookup table the same way must not be rejected.
|
||||
writeModule('seeder', {
|
||||
schema: [
|
||||
'CREATE TABLE IF NOT EXISTS seeder_kinds (id INT PRIMARY KEY, label VARCHAR(32));',
|
||||
"INSERT IGNORE INTO seeder_kinds (id, label) VALUES (1, 'first');",
|
||||
"UPDATE seeder_kinds SET label = 'first' WHERE id = 1;",
|
||||
].join('\n'),
|
||||
})
|
||||
assert.equal(stateOf(freshLoader(tmpRoot), 'seeder').state, 'registered')
|
||||
})
|
||||
|
||||
test('a CREATE TABLE without IF NOT EXISTS is rejected', () => {
|
||||
// It succeeds exactly once and fails every boot after it, which presents as a
|
||||
// module that worked until the first restart — the worst kind of bug to ship
|
||||
// to an operator, and free to catch by reading the file.
|
||||
writeModule('once', { schema: 'CREATE TABLE once_x (id INT);' })
|
||||
assert.match(stateOf(freshLoader(tmpRoot), 'once').reason, /CREATE TABLE without IF NOT EXISTS/)
|
||||
})
|
||||
|
||||
test('a fragment carrying an unreadable file fails the module, not the boot', () => {
|
||||
writeModule('missing', { schema: 'CREATE TABLE IF NOT EXISTS missing_x (id INT);' })
|
||||
fs.unlinkSync(path.join(tmpRoot, 'missing', 'schema.sql'))
|
||||
writeModule('fine', oneRoute('/ok'))
|
||||
|
||||
const loader = freshLoader(tmpRoot)
|
||||
assert.equal(stateOf(loader, 'missing').state, 'startup_failed')
|
||||
assert.equal(stateOf(loader, 'fine').state, 'registered')
|
||||
})
|
||||
|
||||
test('fragments() lists only registered modules that have one', () => {
|
||||
writeModule('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
|
||||
writeModule('nodb', oneRoute('/plain'))
|
||||
writeModule('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' })
|
||||
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const frags = loader.fragments()
|
||||
|
||||
assert.deepEqual(frags.map((f) => f.id), ['withdb'])
|
||||
// An absolute path, so the replay never has to know how a module dir is laid out.
|
||||
assert.equal(frags[0].file, path.join(tmpRoot, 'withdb', 'schema.sql'))
|
||||
})
|
||||
|
||||
test('fragments() before load() throws, like every other accessor', () => {
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
assert.throws(() => loader.fragments(), /modules\.fragments\(\) before modules\.load\(\)/)
|
||||
})
|
||||
|
||||
// ── Mounting and the dispatch guard ────────────────────────────────────────
|
||||
|
||||
test('a registered module answers on its prefix; a failed one is simply absent', async () => {
|
||||
writeModule('good', oneRoute('/widgets'))
|
||||
writeModule('bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' })
|
||||
|
||||
const tiers = emptyTiers()
|
||||
freshLoader(tmpRoot, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
||||
// Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do
|
||||
// not exist at all (§4.4, left-hand column).
|
||||
assert.equal((await fetch(`${app.url}/public/broken`)).status, 404)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a module that fails AFTER mounting keeps its URLs and answers 503', async () => {
|
||||
// The right-hand column of §4.4, and the reason routes.manifest.json can be
|
||||
// generated off a dead database: the URL surface must not depend on whether a
|
||||
// boot step succeeded on the generating machine. The schema replay and onBoot
|
||||
// are the two things that trip this in real life (moduleSchema.test.js and
|
||||
// moduleLifecycle.test.js cover both); here the state is moved by hand,
|
||||
// because the loader is the thing under test.
|
||||
writeModule('later', oneRoute('/widgets'))
|
||||
|
||||
const tiers = emptyTiers()
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
||||
|
||||
assert.equal(stateOf(loader, 'later').state, 'registered')
|
||||
|
||||
loader.setState('later', 'startup_failed', { stage: 'schema', reason: 'schema fragment blew up' })
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
|
||||
assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up')
|
||||
assert.equal(stateOf(loader, 'later').stage, 'schema')
|
||||
|
||||
// The 404 leg is reached for real by the boot reconcile, when it finds a
|
||||
// `disabled` row in installed_modules. A disabled module is mounted and
|
||||
// guarded, never unmounted (§4.5) — same reason as the 503.
|
||||
loader.setState('later', 'disabled')
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404)
|
||||
|
||||
loader.setState('later', 'started')
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
||||
// Every non-failing move clears the failure, so a running module can never
|
||||
// show the reason it failed two boots ago (§2.4).
|
||||
assert.equal(stateOf(loader, 'later').reason, null)
|
||||
assert.equal(stateOf(loader, 'later').stage, null)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('setState refuses a state that is not one, and shrugs at an unknown id', () => {
|
||||
writeModule('here', oneRoute('/widgets'))
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.throws(() => loader.setState('here', 'enabled'), /unknown module state "enabled"/)
|
||||
// An id with no record is not the boot path's problem to escalate.
|
||||
assert.doesNotThrow(() => loader.setState('never-installed', 'started'))
|
||||
})
|
||||
|
||||
// ── ctx ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
const seen = path.join(tmpRoot, 'probe-out.json')
|
||||
writeModule('probe', {
|
||||
@@ -227,12 +495,11 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({
|
||||
keys: Object.keys(ctx).sort(),
|
||||
middleware: Object.keys(ctx.middleware).sort(),
|
||||
moduleId: ctx.moduleId,
|
||||
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'))
|
||||
@@ -241,16 +508,135 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
'posts', 'push', 'secretBox', 'settings', 'uploads', 'validator',
|
||||
])
|
||||
assert.deepEqual(probe.middleware, ['noindex', 'requireAuth', 'requireRole', 'siteMode', 'validate'])
|
||||
assert.equal(probe.moduleId, 'probe')
|
||||
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, '\\.')}'`))
|
||||
// ── Lifecycle hooks ────────────────────────────────────────────────────────
|
||||
|
||||
test('a lifecycle hook must be a function, and may be registered once', () => {
|
||||
// Both are register-time failures, so they cost the module its mount entirely
|
||||
// rather than surfacing at boot — the far end of a hook that was never really
|
||||
// registered is a module that silently never warms up.
|
||||
for (const [body, expected] of [
|
||||
['api.onBoot("later")', /onBoot: expected a function/],
|
||||
['api.onShutdown("later")', /onShutdown: expected a function/],
|
||||
['api.onBoot(() => {}); api.onBoot(() => {})', /onBoot\(\) called twice/],
|
||||
]) {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
|
||||
writeModule('hooked', { server: `module.exports = (ctx, api) => { ${body} }` })
|
||||
const state = stateOf(freshLoader(tmpRoot), 'hooked')
|
||||
assert.match(state.reason, expected)
|
||||
assert.equal(state.stage, 'register')
|
||||
}
|
||||
})
|
||||
|
||||
test('a module with no hooks is bootable, and offers nothing to shut down', () => {
|
||||
writeModule('quiet', oneRoute('/widgets'))
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
// Listed with a null hook rather than filtered out: it still has to reach
|
||||
// `started`, or the admin panel and the dispatch guard would disagree about
|
||||
// whether it is serving.
|
||||
assert.deepEqual(loader.bootable().map((b) => b.id), ['quiet'])
|
||||
assert.equal(loader.bootable()[0].hook, null)
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
})
|
||||
|
||||
test('shutdown hooks come back in reverse order, and only for started modules', () => {
|
||||
const hook = 'module.exports = (ctx, api) => api.onShutdown(async () => {})'
|
||||
writeModule('aaa', { server: hook })
|
||||
writeModule('bbb', { server: hook })
|
||||
writeModule('ccc', { server: hook })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
// Nothing has started yet, so there is nothing to tear down.
|
||||
assert.deepEqual(loader.shutdownHooks(), [])
|
||||
|
||||
loader.setState('aaa', 'started')
|
||||
loader.setState('bbb', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
|
||||
loader.setState('ccc', 'started')
|
||||
|
||||
// Reverse registration order (§2.5), and `bbb` is absent: a module whose
|
||||
// onBoot threw is mid-way through a warm-up it never finished, and handing it
|
||||
// a half-built world to tear down is worse than not closing cleanly.
|
||||
assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['ccc', 'aaa'])
|
||||
})
|
||||
|
||||
// ── Failure stages ─────────────────────────────────────────────────────────
|
||||
|
||||
test('a failure is recorded against the §4.3 step that produced it', () => {
|
||||
// installed_modules.failure_stage exists so the admin panel can say WHERE a
|
||||
// module broke. The four steps readManifest covers in one pass have to label
|
||||
// themselves; the rest are inferred from how far load() had got.
|
||||
const cases = [
|
||||
['a-manifest', { manifest: { nonsense: true } }, 'manifest'],
|
||||
['b-coreapi', { manifest: { coreApi: '^99.0.0' } }, 'core_api'],
|
||||
['c-mounts', { manifest: { mounts: { public: ['/bad prefix'] } } }, 'mounts'],
|
||||
['d-slots', { manifest: { extensions: ['no.such.slot'] } }, 'extensions'],
|
||||
['e-schema', { schema: 'DELETE FROM x;' }, 'schema'],
|
||||
['f-require', { server: 'throw new Error("boom")' }, 'require'],
|
||||
['g-register', { server: 'module.exports = (ctx, api) => { throw new Error("nope") }' }, 'register'],
|
||||
]
|
||||
for (const [id, spec] of cases) writeModule(id, spec)
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
for (const [id, , stage] of cases) {
|
||||
const state = stateOf(loader, id)
|
||||
assert.equal(state.state, 'startup_failed', `${id} should have failed`)
|
||||
assert.equal(state.stage, stage, `${id} should have failed at "${stage}"`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Staged registrations are committed only for a module that survives ─────
|
||||
|
||||
test('a module that fails AFTER registering leaves nothing in the registries', () => {
|
||||
// The registry-side twin of the second-pass mount rule. register() runs before
|
||||
// checkDeclared, so a module can stage a stream catalog and then be rejected —
|
||||
// and a half-registered catalog is worse than a missing one, because it is a
|
||||
// subscribable stream nothing will ever publish to.
|
||||
writeModule('halfway', {
|
||||
manifest: { mounts: { public: ['/declared'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
|
||||
api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
|
||||
// declared /declared and never registered it → rejected by checkDeclared
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
|
||||
assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
|
||||
assert.equal(registries.isValidStream('halfway.thing'), false)
|
||||
assert.equal(registries.announceLeg('halfway.leg'), null)
|
||||
})
|
||||
|
||||
test('a module colliding with an already-registered name fails alone, unmounted', () => {
|
||||
const tiers = emptyTiers()
|
||||
writeModule('first', {
|
||||
manifest: { mounts: { public: ['/first'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/first': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
|
||||
}`,
|
||||
})
|
||||
writeModule('second', {
|
||||
manifest: { mounts: { public: ['/second'] } },
|
||||
server: `module.exports = (ctx, api) => {
|
||||
api.registerRoutes({ public: { '/second': ctx.express.Router() } })
|
||||
api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
|
||||
}`,
|
||||
})
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
|
||||
assert.equal(stateOf(loader, 'first').state, 'registered')
|
||||
assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
|
||||
// Not even the claim that did not collide.
|
||||
assert.equal(registries.isValidStream('second.ok'), false)
|
||||
// And the loser is not mounted at all. Asked of the live router the way the
|
||||
// prefix-ownership check asks it, rather than by counting layers — one mount
|
||||
// produces two (the dispatch guard, then the module's router).
|
||||
const claims = (prefix) =>
|
||||
tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
|
||||
assert.equal(claims('/first'), true)
|
||||
assert.equal(claims('/second'), false)
|
||||
})
|
||||
|
||||
216
server/test/moduleRegistries.test.js
Normal file
216
server/test/moduleRegistries.test.js
Normal file
@@ -0,0 +1,216 @@
|
||||
// ── The three de-entanglement registries ───────────────────────────────────
|
||||
//
|
||||
// Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. The properties worth a test
|
||||
// are the ones nobody exercises by hand: what happens when two registrants want
|
||||
// the same name, and what is left behind when one of them fails halfway.
|
||||
//
|
||||
// Point the DB at a closed port BEFORE requiring anything — registerCore() pulls
|
||||
// in the announce legs, which pull in models that build a pool at require time.
|
||||
// No query is ever run.
|
||||
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 registries = require('../src/modules/registries')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
// Declares `admin.users.detail` the way production does — at require time, in the
|
||||
// router that owns the resource.
|
||||
require('../src/router/v1/admin')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
|
||||
const stream = (id, over = {}) => ({ id, label: id, ...over })
|
||||
const leg = (id, over = {}) => ({ leg: id, label: id, dispatch: async () => ({ ok: true }), classify: () => ({ outcome: 'done' }), ...over })
|
||||
|
||||
/** Register a batch as `owner` and return the error message, or null on success. */
|
||||
function tryApply(owner, build) {
|
||||
const api = registries.stage(owner)
|
||||
try {
|
||||
build(api)
|
||||
registries.apply(api.staged)
|
||||
return null
|
||||
} catch (err) {
|
||||
return err.message
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core goes through the same door ────────────────────────────────────────
|
||||
|
||||
test('registerCore registers core AND the not-yet-extracted shard content', () => {
|
||||
registries.registerCore()
|
||||
|
||||
const ids = registries.allStreams().map((s) => s.id)
|
||||
// Core's one stream first, then the seven that leave with module-uo.
|
||||
assert.equal(ids[0], 'news.post')
|
||||
assert.equal(ids.length, 8)
|
||||
assert.ok(ids.includes('vendor.sale'))
|
||||
|
||||
assert.deepEqual(registries.announceLegIds(), ['discord', 'towncrier'])
|
||||
assert.equal(registries.slotFilledBy('admin.users.detail'), 'core')
|
||||
assert.equal(registries.isCoreRegistered(), true)
|
||||
})
|
||||
|
||||
test('registerCore is idempotent — a second call registers nothing twice', () => {
|
||||
registries.registerCore()
|
||||
const before = registries.allStreams().length
|
||||
registries.registerCore()
|
||||
assert.equal(registries.allStreams().length, before)
|
||||
})
|
||||
|
||||
test('the wire shape of a stream survives registration', () => {
|
||||
registries.registerCore()
|
||||
const personal = registries.allStreams().find((s) => s.id === 'vendor.sale')
|
||||
// Two booleans, not the contract's single `scope`: this object is the body of
|
||||
// GET /auth/me/notifications/streams and a shipped Android client reads both.
|
||||
assert.equal(personal.personal, true)
|
||||
assert.equal(personal.requiresLinkedAccount, true)
|
||||
assert.ok(personal.description.length > 0)
|
||||
assert.ok(registries.personalStreams().has('vendor.sale'))
|
||||
assert.equal(registries.isValidStream('vendor.sale'), true)
|
||||
assert.equal(registries.isValidStream('nope.nope'), false)
|
||||
})
|
||||
|
||||
// ── Namespacing ────────────────────────────────────────────────────────────
|
||||
|
||||
test('a module’s stream ids must carry its module id', () => {
|
||||
assert.equal(tryApply('rust', (api) => api.registerNotificationStreams([stream('rust.raid')])), null)
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerNotificationStreams([stream('raid.started')])),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
test('the seven pre-module-system stream ids are grandfathered to uo alone', () => {
|
||||
// Renaming them would be a data migration (notification_subs rows) and a break
|
||||
// for a shipped Android client — the same reasoning as the loader's legacy
|
||||
// table prefixes.
|
||||
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([stream('vendor.sale')])), null)
|
||||
registries._reset()
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerNotificationStreams([stream('vendor.sale')])),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
test('an announce leg must be namespaced too, with towncrier grandfathered to uo', () => {
|
||||
assert.equal(tryApply('uo', (api) => api.registerAnnounceLeg(leg('towncrier'))), null)
|
||||
registries._reset()
|
||||
assert.equal(tryApply('rust', (api) => api.registerAnnounceLeg(leg('rust.motd'))), null)
|
||||
registries._reset()
|
||||
assert.match(
|
||||
tryApply('rust', (api) => api.registerAnnounceLeg(leg('towncrier'))),
|
||||
/not namespaced "rust\."/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Collisions name the holder ─────────────────────────────────────────────
|
||||
|
||||
test('a stream core already registered is refused, naming core', () => {
|
||||
registries.registerCore()
|
||||
assert.match(
|
||||
tryApply('uo', (api) => api.registerNotificationStreams([stream('news.post')])),
|
||||
/already registered by "core"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('two modules cannot register the same stream or leg', () => {
|
||||
assert.equal(tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])), null)
|
||||
// A second module can only reach it via its own namespace, so collide on a
|
||||
// grandfathered id, which is the realistic case.
|
||||
assert.match(
|
||||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])),
|
||||
/already registered by "aaa"/,
|
||||
)
|
||||
assert.equal(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), null)
|
||||
assert.match(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), /already registered by "bbb"/)
|
||||
})
|
||||
|
||||
test('a batch cannot claim the same name twice', () => {
|
||||
assert.match(
|
||||
tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.x'), stream('aaa.x')])),
|
||||
/registered twice/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── Validate-then-commit ───────────────────────────────────────────────────
|
||||
|
||||
test('a batch whose LAST claim collides commits none of the earlier ones', () => {
|
||||
// The property the whole staging design exists for. A half-registered catalog
|
||||
// is worse than a missing one: a subscribable stream nothing will publish to.
|
||||
registries.registerCore()
|
||||
const before = registries.allStreams().length
|
||||
|
||||
const err = tryApply('uo', (api) => {
|
||||
api.registerNotificationStreams([stream('uo.first'), stream('uo.second')])
|
||||
api.registerAnnounceLeg(leg('uo.leg'))
|
||||
api.registerNotificationStreams([stream('news.post')]) // collides with core
|
||||
})
|
||||
|
||||
assert.match(err, /already registered by "core"/)
|
||||
assert.equal(registries.allStreams().length, before, 'uo.first / uo.second must not be registered')
|
||||
assert.equal(registries.isValidStream('uo.first'), false)
|
||||
assert.equal(registries.announceLeg('uo.leg'), null)
|
||||
})
|
||||
|
||||
test('staging alone changes nothing — only apply() commits', () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerNotificationStreams([stream('uo.staged')])
|
||||
assert.equal(registries.isValidStream('uo.staged'), false)
|
||||
registries.apply(api.staged)
|
||||
assert.equal(registries.isValidStream('uo.staged'), true)
|
||||
})
|
||||
|
||||
// ── Shape checks fire at the call ──────────────────────────────────────────
|
||||
|
||||
test('a malformed claim throws where the registrant made it, not at apply()', () => {
|
||||
const api = registries.stage('uo')
|
||||
assert.throws(() => api.registerNotificationStreams([stream('nodots')]), /bad stream id/)
|
||||
assert.throws(() => api.registerNotificationStreams([{ id: 'uo.x' }]), /has no label/)
|
||||
assert.throws(() => api.registerNotificationStreams('not an array'), /expected an array/)
|
||||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { dispatch: null })), /has no dispatch/)
|
||||
assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { classify: null })), /has no classify/)
|
||||
assert.throws(() => api.registerAnnounceLeg({ leg: 'NOPE' }), /bad leg id/)
|
||||
})
|
||||
|
||||
// ── Extension slots ────────────────────────────────────────────────────────
|
||||
|
||||
test('only a declared slot can be filled, and only once', () => {
|
||||
const router = () => {}
|
||||
const api = registries.stage('uo')
|
||||
assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
|
||||
assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)
|
||||
|
||||
registries.registerCore() // core fills it
|
||||
assert.match(
|
||||
tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)),
|
||||
/already filled by "core"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a slot cannot be declared twice', () => {
|
||||
assert.throws(() => registries.declareSlot('admin.users.detail'), /already declared/)
|
||||
})
|
||||
|
||||
// ── The slot's spec, which static analysis cannot see ──────────────────────
|
||||
|
||||
test('the filled slot’s router is findable in the live app, at the resource path', () => {
|
||||
// Guards swagger/slotSpecs.js: it recovers each slot's mount prefix from the
|
||||
// live stack rather than a hardcoded table. If this stops working, the six
|
||||
// slot routes vanish from swagger-output.json with `Success` printed — the
|
||||
// exact silent failure the spike hit (MODULE_API.md §7.4).
|
||||
/* eslint-disable global-require */
|
||||
const app = require('../src/app')
|
||||
const { findMountPrefix } = require('../swagger/slotSpecs')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const [slot] = registries.filledSlots()
|
||||
assert.ok(slot, 'app.js must have registered core, filling the slot')
|
||||
assert.equal(slot.slot, 'admin.users.detail')
|
||||
assert.ok(slot.specFile, 'core names the file its slot router is generated from')
|
||||
assert.equal(findMountPrefix(app._router.stack, slot.router), '/api/v1/admin/users/:id')
|
||||
})
|
||||
247
server/test/moduleSchema.test.js
Normal file
247
server/test/moduleSchema.test.js
Normal file
@@ -0,0 +1,247 @@
|
||||
// ── Replaying module schema fragments ──────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 3. The contract is MODULE_API.md §2.6 (a fragment is replayed by
|
||||
// the same ensureSchema() that replays core's, statement by statement, split the
|
||||
// same way) and §4.4 (a failure after mounting is a state, not a crash).
|
||||
//
|
||||
// The property under test throughout, as in moduleLoader.test.js: **the failing
|
||||
// module fails alone.** A fragment that blows up must cost its own module its
|
||||
// routes and nothing else — not core's boot, not the next module's tables.
|
||||
//
|
||||
// No database is involved. `replayFragments` takes its `query` as an injectable
|
||||
// dependency precisely so this suite can assert on the exact statements that
|
||||
// would have been executed, in order, with the pool pointed at a dead port like
|
||||
// every other suite here.
|
||||
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 express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const { replayFragments } = require('../src/modules/schema')
|
||||
const { splitStatements } = require('../src/utils/sqlStatements')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let tmpRoot
|
||||
|
||||
const emptyTiers = () => ({
|
||||
public: express.Router(),
|
||||
admin: express.Router(),
|
||||
player: express.Router(),
|
||||
})
|
||||
|
||||
function freshLoader(dir, tiers = emptyTiers()) {
|
||||
process.env.MODULES_DIR = dir
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(tiers)
|
||||
return loader
|
||||
}
|
||||
|
||||
/** A module with a valid fragment, and optionally a route to watch 503 later. */
|
||||
function writeModule(id, { schema, mounts } = {}) {
|
||||
const dir = path.join(tmpRoot, id)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
||||
id,
|
||||
name: id,
|
||||
version: '1.0.0',
|
||||
coreApi: '^1.0.0',
|
||||
...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }),
|
||||
...(mounts === undefined ? {} : { mounts, server: 'index.js' }),
|
||||
}))
|
||||
if (schema !== undefined) {
|
||||
fs.writeFileSync(path.join(dir, 'schema.sql'), schema)
|
||||
fs.writeFileSync(path.join(dir, 'purge.sql'), `DROP TABLE IF EXISTS ${id}_x;`)
|
||||
}
|
||||
if (mounts !== undefined) {
|
||||
const [prefix] = mounts.public
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), `module.exports = (ctx, api) => {
|
||||
const r = ctx.express.Router()
|
||||
r.get('/', (req, res) => res.json({ ok: true }))
|
||||
api.registerRoutes({ public: { '${prefix}': r } })
|
||||
}`)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
/** A query fn that records what it was asked to run, and can be told to fail. */
|
||||
function recorder(failOn = null) {
|
||||
const ran = []
|
||||
return {
|
||||
ran,
|
||||
query: async (sql) => {
|
||||
ran.push(sql)
|
||||
if (failOn && sql.includes(failOn)) throw new Error(`ER_PARSE_ERROR: near "${failOn}"`)
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-schema-'))
|
||||
})
|
||||
|
||||
// ── The happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
test('a fragment is replayed statement by statement, in file order', async () => {
|
||||
writeModule('alpha', {
|
||||
schema: [
|
||||
'CREATE TABLE IF NOT EXISTS alpha_a (id INT);',
|
||||
'CREATE TABLE IF NOT EXISTS alpha_b (id INT);',
|
||||
'ALTER TABLE alpha_a ADD COLUMN IF NOT EXISTS name VARCHAR(64);',
|
||||
].join('\n'),
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder()
|
||||
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
|
||||
assert.equal(rec.ran.length, 3)
|
||||
// Order is load-bearing, not incidental: the ALTER depends on the CREATE above
|
||||
// it, which is why the replay awaits each statement rather than Promise.all.
|
||||
assert.match(rec.ran[0], /alpha_a/)
|
||||
assert.match(rec.ran[1], /alpha_b/)
|
||||
assert.match(rec.ran[2], /^ALTER TABLE alpha_a/)
|
||||
assert.equal(stateOf(loader, 'alpha').state, 'registered')
|
||||
})
|
||||
|
||||
test('a module with no fragment is skipped, not replayed as empty', async () => {
|
||||
writeModule('nodb')
|
||||
const rec = recorder()
|
||||
|
||||
await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) })
|
||||
|
||||
assert.deepEqual(rec.ran, [])
|
||||
})
|
||||
|
||||
test('fragments are split exactly the way core schema.sql is', async () => {
|
||||
// §2.6's "split the same way" is a promise about shared code, so the thing
|
||||
// worth asserting is that the module path produces what the shared splitter
|
||||
// produces — including the trailing-comment case that would otherwise chop a
|
||||
// statement in half at the `;` inside it.
|
||||
const sql = [
|
||||
'-- a leading comment block',
|
||||
'-- with two lines; and a semicolon in it',
|
||||
'CREATE TABLE IF NOT EXISTS beta_a (id INT); -- trailing; comment',
|
||||
'',
|
||||
'CREATE TABLE IF NOT EXISTS beta_b (id INT);',
|
||||
].join('\n')
|
||||
writeModule('beta', { schema: sql })
|
||||
const rec = recorder()
|
||||
|
||||
await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) })
|
||||
|
||||
assert.deepEqual(rec.ran, splitStatements(sql))
|
||||
assert.equal(rec.ran.length, 2)
|
||||
})
|
||||
|
||||
// ── Failure is a state ─────────────────────────────────────────────────────
|
||||
|
||||
test('a fragment that throws fails its own module and no one else', async () => {
|
||||
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_x (id INT);' })
|
||||
writeModule('bbb', { schema: 'CREATE TABLE IF NOT EXISTS bbb_boom (id INT);' })
|
||||
writeModule('ccc', { schema: 'CREATE TABLE IF NOT EXISTS ccc_x (id INT);' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder('bbb_boom')
|
||||
|
||||
// Never throws — this is called on the boot path, between core's schema and
|
||||
// seedDefaults(), and one bad module must not stop the site coming up.
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
|
||||
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
||||
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
|
||||
assert.match(stateOf(loader, 'bbb').reason, /ER_PARSE_ERROR/)
|
||||
// The one that matters: the module AFTER the failure still got its tables.
|
||||
assert.equal(stateOf(loader, 'ccc').state, 'registered')
|
||||
assert.equal(rec.ran.length, 3)
|
||||
})
|
||||
|
||||
test('a fragment stops at its first failing statement', async () => {
|
||||
writeModule('part', {
|
||||
schema: [
|
||||
'CREATE TABLE IF NOT EXISTS part_a (id INT);',
|
||||
'CREATE TABLE IF NOT EXISTS part_bad (id INT);',
|
||||
'CREATE TABLE IF NOT EXISTS part_c (id INT);',
|
||||
].join('\n'),
|
||||
})
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder('part_bad')
|
||||
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
|
||||
// Two attempted, the third never reached. The first table survives, and is
|
||||
// accepted rather than compensated for: DDL self-commits in MariaDB, so no
|
||||
// transaction could roll it back, and §2.6's idempotence rule is what makes
|
||||
// re-running the corrected fragment safe.
|
||||
assert.equal(rec.ran.length, 2)
|
||||
assert.equal(stateOf(loader, 'part').state, 'startup_failed')
|
||||
})
|
||||
|
||||
test('a module whose fragment failed keeps its URLs and answers 503', async () => {
|
||||
// §4.4's right-hand column, now reachable for real rather than by a hand-moved
|
||||
// state: schema replay is the first thing in the lifecycle that fails AFTER
|
||||
// the routes are already mounted.
|
||||
writeModule('svc', {
|
||||
schema: 'CREATE TABLE IF NOT EXISTS svc_boom (id INT);',
|
||||
mounts: { public: ['/widgets'] },
|
||||
})
|
||||
const tiers = emptyTiers()
|
||||
const loader = freshLoader(tmpRoot, tiers)
|
||||
const app = await startApp((a) => a.use('/public', tiers.public))
|
||||
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
||||
|
||||
await replayFragments({ query: recorder('svc_boom').query, modules: loader })
|
||||
|
||||
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a module that failed validation is not replayed at all', async () => {
|
||||
// It is never going to run, so creating its tables would leave an operator
|
||||
// with rows belonging to a module that does not load.
|
||||
writeModule('bad', { schema: 'CREATE TABLE IF NOT EXISTS not_prefixed (id INT);' })
|
||||
writeModule('good', { schema: 'CREATE TABLE IF NOT EXISTS good_x (id INT);' })
|
||||
const loader = freshLoader(tmpRoot)
|
||||
const rec = recorder()
|
||||
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
|
||||
assert.equal(stateOf(loader, 'bad').state, 'startup_failed')
|
||||
assert.equal(rec.ran.length, 1)
|
||||
assert.match(rec.ran[0], /good_x/)
|
||||
})
|
||||
|
||||
// ── The seed script ────────────────────────────────────────────────────────
|
||||
|
||||
test('replay is skipped, not thrown, when no scan happened in this process', async () => {
|
||||
// `npm run seed` (db/seed.js) calls ensureSchema() standalone without ever
|
||||
// requiring app.js, so the loader never ran. Before this was handled it was
|
||||
// fragments()'s §7.6 throw, which would have broken seeding outright.
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = require('../src/modules/loader')
|
||||
const rec = recorder()
|
||||
|
||||
await replayFragments({ query: rec.query, modules: loader })
|
||||
|
||||
assert.deepEqual(rec.ran, [])
|
||||
assert.equal(loader.isLoaded(), false)
|
||||
})
|
||||
295
server/test/modules.model.test.js
Normal file
295
server/test/modules.model.test.js
Normal file
@@ -0,0 +1,295 @@
|
||||
// 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)
|
||||
})
|
||||
@@ -6,8 +6,11 @@ process.env.DB_PORT = '59999'
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { mapShardEvent, createTracker } = require('../src/config/notificationStreams')
|
||||
const { mapShardEvent, createTracker } = require('../src/config/shardStreams')
|
||||
const pushDispatch = require('../src/utils/pushDispatch')
|
||||
// fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard
|
||||
// side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8).
|
||||
const shardPush = require('../src/utils/shardPush')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -202,14 +205,14 @@ test('fromShardEvent resolves a personal event to the owning user, or drops it i
|
||||
}
|
||||
const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() }
|
||||
|
||||
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
|
||||
assert.equal(calls.length, 1)
|
||||
|
||||
// Unlinked account → nobody to notify → no publish.
|
||||
userStreamCalls.length = 0
|
||||
calls.length = 0
|
||||
await pushDispatch.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||
assert.equal(userStreamCalls.length, 0)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
@@ -226,7 +229,7 @@ test('fromShardEvent fans a public shard event to the stream’s subscribers', a
|
||||
},
|
||||
endpointsForUserStream: async () => [],
|
||||
}
|
||||
await pushDispatch.fromShardEvent(
|
||||
await shardPush.fromShardEvent(
|
||||
{ kind: 'server.hello', bootId: 'b1' },
|
||||
{ pushDevices, fetchImpl, tracker: createTracker() },
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ const {
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
} = require('../utils/spawnAtlasParse')
|
||||
} = require('../src/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,5 +1,6 @@
|
||||
// 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).
|
||||
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')
|
||||
@@ -7,10 +8,6 @@ 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,
|
||||
@@ -19,13 +16,13 @@ const {
|
||||
hashSources,
|
||||
buildAtlas,
|
||||
PARSER_VERSION,
|
||||
} = 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')
|
||||
} = 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())
|
||||
|
||||
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||||
//
|
||||
Reference in New Issue
Block a user