Compare commits
1 Commits
0a3f1eb9fa
...
spike/modu
| Author | SHA1 | Date | |
|---|---|---|---|
| bf470c7658 |
@@ -6,7 +6,6 @@ import RequireAuth from './components/RequireAuth.jsx'
|
||||
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||
import RoleGate from './components/RoleGate.jsx'
|
||||
import { routesFor } from './modules/registry.js'
|
||||
import { ModuleFeaturesProvider } from './modules/features.jsx'
|
||||
|
||||
// Public
|
||||
import Portal from './routes/public/Portal.jsx'
|
||||
@@ -25,8 +24,6 @@ import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
import Atlas from './routes/public/Atlas.jsx'
|
||||
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||
import Market from './routes/public/Market.jsx'
|
||||
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||
@@ -81,198 +78,173 @@ export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SiteProvider>
|
||||
{/* Inside the auth and site contexts, because a feature provider is a
|
||||
hook that may well read either — the shard one does, indirectly, by
|
||||
asking an endpoint whose answer depends on the session. Outside the
|
||||
routes, so the nav in every layout is filtered by the same gate and
|
||||
the provider hooks are called once for the whole app rather than
|
||||
once per screen. */}
|
||||
<ModuleFeaturesProvider>
|
||||
<Routes>
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
||||
<Route path="/" element={<Portal />} />
|
||||
<Routes>
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
||||
<Route path="/" element={<Portal />} />
|
||||
|
||||
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||
<Route
|
||||
element={
|
||||
<MaintenanceGate>
|
||||
<Outlet />
|
||||
</MaintenanceGate>
|
||||
}
|
||||
>
|
||||
<Route path="/site" element={<Website />} />
|
||||
<Route path="/site/news" element={<News />} />
|
||||
<Route path="/site/screenshots" element={<Screenshots />} />
|
||||
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/site/rules" element={<Rules />} />
|
||||
<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 />} />
|
||||
</Route>
|
||||
|
||||
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
||||
preview link works regardless of site mode. */}
|
||||
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostsAdmin />} />
|
||||
<Route path="pages" element={<PagesAdmin />} />
|
||||
<Route path="pages/new" element={<PageBuilder />} />
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
{/* Theme editing writes an admin-only settings key; the route sits
|
||||
behind the same RoleGate as the sidebar entry that reaches it,
|
||||
and PUT/DELETE /admin/settings is admin-only server-side too. */}
|
||||
<Route
|
||||
path="appearance"
|
||||
element={
|
||||
<MaintenanceGate>
|
||||
<RoleGate roles={['admin']}>
|
||||
<AppearanceAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
{/* Same reasoning as Appearance: the nav overrides are an admin-only
|
||||
settings key, so the route carries the same RoleGate as the
|
||||
sidebar entry that reaches it. */}
|
||||
<Route
|
||||
path="navigation"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<NavEditor />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path="moderation"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<Outlet />
|
||||
</MaintenanceGate>
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route path="/site" element={<Website />} />
|
||||
<Route path="/site/news" element={<News />} />
|
||||
<Route path="/site/screenshots" element={<Screenshots />} />
|
||||
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<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>/…` — the
|
||||
registry prefixes the segment, so a module cannot spell its way
|
||||
out of it (docs/website/MODULE_API.md §3.3). Declared before the
|
||||
CMS catch-all below: React Router ranks a static segment over a
|
||||
dynamic one, so the order is not what saves us, but keeping the
|
||||
two adjacent makes the relationship visible to whoever adds the
|
||||
next route here. */}
|
||||
{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 />} />
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
</Route>
|
||||
|
||||
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
||||
preview link works regardless of site mode. */}
|
||||
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
path="shard-ops"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostsAdmin />} />
|
||||
<Route path="pages" element={<PagesAdmin />} />
|
||||
<Route path="pages/new" element={<PageBuilder />} />
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
{/* Theme editing writes an admin-only settings key; the route sits
|
||||
behind the same RoleGate as the sidebar entry that reaches it,
|
||||
and PUT/DELETE /admin/settings is admin-only server-side too. */}
|
||||
<Route
|
||||
path="appearance"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<AppearanceAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
{/* Same reasoning as Appearance: the nav overrides are an admin-only
|
||||
settings key, so the route carries the same RoleGate as the
|
||||
sidebar entry that reaches it. */}
|
||||
<Route
|
||||
path="navigation"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<NavEditor />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path="moderation"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<Outlet />
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
|
||||
<Route
|
||||
path="shard-ops"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<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 }, which core applies as the
|
||||
same RoleGate its own routes above use, so the sidebar and the
|
||||
route table cannot disagree about who may see what. Before the
|
||||
`*` redirect, which would otherwise swallow every one of them. */}
|
||||
{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>
|
||||
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
place the three areas do not read alike. */}
|
||||
{routesFor('player').map((r) => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={`/player/${r.path}`}
|
||||
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<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>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</ModuleFeaturesProvider>
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
}
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</SiteProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
@@ -42,15 +42,14 @@ function safeParse(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, exported for installed modules and handed to them on
|
||||
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
|
||||
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
|
||||
// on a non-2xx — and nothing above them: a module owns the paths it calls,
|
||||
// because it owns the routes at the other end.
|
||||
// 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.
|
||||
//
|
||||
// The `api` object 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.
|
||||
// `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 = {
|
||||
|
||||
@@ -4,28 +4,23 @@ import MoonDot from './MoonDot.jsx'
|
||||
import BrandLogo from './BrandLogo.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||
import NavDropdown from './NavDropdown.jsx'
|
||||
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
|
||||
import { navFor } from '../modules/registry.js'
|
||||
import { parseJsonSetting } from '../lib/settingsJson.js'
|
||||
import { withModuleNav } from '../modules/nav.js'
|
||||
import { useFeatureGate } from '../modules/features.jsx'
|
||||
|
||||
// One consistent top nav for the whole public site. Every page gets the same
|
||||
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||
//
|
||||
// Entries carrying a `feature` are surfaces an admin can disable or gate to a
|
||||
// higher audience (Admin -> Shard Visibility). They are hidden when this viewer
|
||||
// can't reach them, so we never render a link that would 403. The gate itself is
|
||||
// server-side; this is only about not advertising a dead end. Which module
|
||||
// answers for a given flag is the registry's business now, not this file's —
|
||||
// core registers `useShardFlags` for the ten below and Phase 3 hands them over
|
||||
// (modules/featureGate.js).
|
||||
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
|
||||
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
||||
// viewer can't reach them, so we never render a link that would 403. The gate
|
||||
// itself is server-side; this is only about not advertising a dead end.
|
||||
//
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here,
|
||||
// with this component as its owner: the editor may only relabel, reorder and
|
||||
// hide what it finds, and `to`/`feature` are never its to change (§7). An
|
||||
// installed module's rows join it in `withModuleNav` below — before the override
|
||||
// merge, so an admin can edit those rows exactly as they edit these.
|
||||
// hide what it finds, and `to`/`feature` are never its to change (§7).
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
@@ -39,7 +34,6 @@ export const NAV = [
|
||||
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||
{ label: 'Market', to: '/site/market', feature: 'market' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
@@ -54,12 +48,7 @@ const linkStyle = ({ isActive }) => ({
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle, settings } = useSite()
|
||||
const isVisible = useFeatureGate()
|
||||
|
||||
// Core's rows plus every installed module's. Computed once: the registry is
|
||||
// fixed before the first render and there is no unregistering, so this cannot
|
||||
// change during a session (modules/nav.js).
|
||||
const baseNav = useMemo(() => withModuleNav(NAV, 'public'), [])
|
||||
const shardFeatures = useShardFeatures()
|
||||
|
||||
// An admin may relabel, reorder and hide these entries from Admin →
|
||||
// Navigation, and may group them into dropdown sections alongside links of
|
||||
@@ -72,10 +61,26 @@ export default function SiteHeader() {
|
||||
// never opens onto nothing;
|
||||
// • with no stored row this is the coded NAV, in code order, so an
|
||||
// untouched instance renders exactly what it renders today.
|
||||
// Installed modules' entries interleave into this list by `order` BEFORE the
|
||||
// override merge, so an admin edits one nav rather than "core's, plus whatever
|
||||
// the module appended" — and a module item is hideable and re-labelable
|
||||
// exactly like a core one. `order` defaults high, which lands module entries
|
||||
// where the UO items already sat: after the content links, before About.
|
||||
const base = useMemo(() => {
|
||||
const items = navFor('public')
|
||||
if (items.length === 0) return NAV
|
||||
const merged = [...NAV]
|
||||
for (const item of items) {
|
||||
const at = Number.isFinite(item.order) ? item.order : merged.length
|
||||
merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature })
|
||||
}
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
const nav = useMemo(() => {
|
||||
const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
|
||||
return pruneNav(tree, isVisible)
|
||||
}, [baseNav, settings.nav_public, isVisible])
|
||||
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public))
|
||||
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
}, [base, settings.nav_public, shardFeatures])
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
let account
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// Who may see a row of the admin sidebar, and where that lets them go.
|
||||
//
|
||||
// Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders
|
||||
// by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md
|
||||
// §8.1), and a second copy of this answer is exactly the thing this file exists
|
||||
// to abolish. And it is the closest thing in the client to an authorization
|
||||
// decision, so it belongs somewhere the test runner can reach, which a .jsx file
|
||||
// is not.
|
||||
//
|
||||
// **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was
|
||||
// `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined
|
||||
// moderators, AND a third prefix list in the redirect effect that disagreed with
|
||||
// both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be
|
||||
// added to a list core hardcodes, which is what forced the derivation — but the
|
||||
// lists had already drifted from each other without a module in sight.
|
||||
|
||||
/**
|
||||
* Can a viewer with this role see this row?
|
||||
*
|
||||
* Applied AFTER the override merge in both callers: an override is presentation
|
||||
* and this is the boundary, so an override saying `hidden: false` on a row this
|
||||
* role cannot see still shows nothing (THEMING_AND_NAV.md §7).
|
||||
*
|
||||
* A row with no `roles` is visible to everyone who reached the admin area at
|
||||
* all — that is the self-service case (Account, My Characters), and staff are a
|
||||
* superset of players.
|
||||
*/
|
||||
export function navItemVisibleTo(item, role) {
|
||||
return !item.roles || item.roles.includes(role)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths a viewer with this role may reach, derived from the rows they see.
|
||||
*
|
||||
* Takes the BASE nav, never the override-merged one: an override must not be
|
||||
* able to move this boundary in either direction. Hiding a row from a
|
||||
* moderator's sidebar must not also bar them from the page behind it, and
|
||||
* un-hiding one must not admit them to a page their role does not carry.
|
||||
*
|
||||
* @param {Array<{items: Array}>} baseNav the grouped admin nav
|
||||
* @param {string} role
|
||||
* @returns {Array<{to: string, exact: boolean}>}
|
||||
*/
|
||||
export function allowedPathsFor(baseNav, role) {
|
||||
return (Array.isArray(baseNav) ? baseNav : [])
|
||||
.flatMap((g) => g.items || [])
|
||||
.filter((item) => navItemVisibleTo(item, role))
|
||||
.map((item) => ({ to: item.to, exact: item.end === true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this pathname one of them?
|
||||
*
|
||||
* A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix
|
||||
* of the whole admin area, and treating it as one would let every path through.
|
||||
* Every other row also covers its sub-routes, which is what keeps
|
||||
* `/admin/moderation/appeals/12` and a module's detail pages reachable without
|
||||
* anyone listing them.
|
||||
*/
|
||||
export function isAllowedPath(pathname, allowed) {
|
||||
return (allowed || []).some(({ to, exact }) =>
|
||||
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
|
||||
)
|
||||
}
|
||||
@@ -20,10 +20,7 @@
|
||||
// Two shapes are supported, because two exist:
|
||||
// flat [{ to, label, ... }] — public header, player portal
|
||||
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
|
||||
// Exported for modules/nav.js, which has to answer the same question about the
|
||||
// same array a moment earlier — one implementation, so the interleave and the
|
||||
// merge can never disagree about which shape they are looking at.
|
||||
export function isGrouped(nav) {
|
||||
function isGrouped(nav) {
|
||||
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
||||
}
|
||||
|
||||
|
||||
@@ -56,15 +56,3 @@ export function useShardFeatures() {
|
||||
export function canSee(features, name) {
|
||||
return !features || features.set.has(name)
|
||||
}
|
||||
|
||||
// The same answer in the shape core's generic feature seam takes: a Set-like of
|
||||
// the flags this viewer may see, or null while we do not know yet
|
||||
// (modules/featureGate.js). Core registers THIS as the provider for the `uo`
|
||||
// namespace (main.jsx), so the ten shard-gated rows in the public header are
|
||||
// already resolved through the module seam rather than beside it — when Phase 3
|
||||
// moves those rows into the module, the registration moves with this file and
|
||||
// core is left with nothing to delete.
|
||||
export function useShardFlags() {
|
||||
const features = useShardFeatures()
|
||||
return features ? features.set : null
|
||||
}
|
||||
|
||||
@@ -3,56 +3,24 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { registerFeatureProvider } from './modules/registry.js'
|
||||
import { useShardFlags } from './lib/useShardFeatures.js'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
|
||||
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
|
||||
// after this bundle's own tag; module scripts execute in document order, so they
|
||||
// resolve their externals against the global this call sets up
|
||||
// (docs/website/MODULE_API.md §3.2).
|
||||
// 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()
|
||||
|
||||
// Core registers through the same seam a module uses, and registers FIRST — the
|
||||
// client twin of the server's `registries.registerCore()` (MODULE_SYSTEM.md
|
||||
// §1.9). The ten shard-gated rows in the public header are core's only because
|
||||
// Phase 3 has not moved them yet; routing them through the registry now means
|
||||
// SiteHeader holds one mechanism instead of two, and the extraction becomes a
|
||||
// deletion rather than a rewrite made under extraction pressure.
|
||||
// Render after DOMContentLoaded rather than immediately.
|
||||
//
|
||||
// The owner id is `core`, which is what a nav row with no `moduleId` resolves
|
||||
// against (modules/featureGate.js). The namespace is `uo`, so a module that
|
||||
// wants to read these flags — the `uo` module itself, once it owns them — asks
|
||||
// for them by the name they will always have had.
|
||||
registerFeatureProvider('core', 'uo', useShardFlags)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
// Deferred scripts — which every `type="module"` script is — 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 registered
|
||||
// its routes before React reads the registry: no loading state, no re-render,
|
||||
// and no ordering race between core's bundle and a module's. A module chunk that
|
||||
// 404s or throws does not hold the event back, so a broken module costs its own
|
||||
// pages and not the site.
|
||||
//
|
||||
// The readyState check below is `'complete'`, and it is not the obvious
|
||||
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
|
||||
// after the document has been parsed, so by the time this line executes
|
||||
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
|
||||
// still comes after every deferred script. Testing for `'loading'` therefore
|
||||
// mounts immediately, before any module chunk has evaluated, and a module's
|
||||
// routes are missing from the very first render — which looks exactly like a
|
||||
// module that failed to load: its URL falls through to core's catch-all and
|
||||
// redirects home. Found by loading a real chunk in a browser; no unit test in
|
||||
// this repo can see it.
|
||||
//
|
||||
// `'complete'` is only reached after `load`, which is strictly later than any
|
||||
// static deferred script, so this branch is the genuine "the event has already
|
||||
// been and gone" case and not a wrong guess about our own timing.
|
||||
// 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>
|
||||
@@ -63,8 +31,8 @@ function mount() {
|
||||
)
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
mount()
|
||||
} else {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount, { once: true })
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
// Which nav rows a viewer may see, when the answer belongs to a module.
|
||||
//
|
||||
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
|
||||
// the contract is docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// Ten of the sixteen rows in the public header carry a `feature`, and every one
|
||||
// of them is a shard surface an admin can disable or gate to a higher audience.
|
||||
// The provider that answers those questions — `useShardFeatures` — moves out
|
||||
// with the module, so core cannot keep calling it directly and still be a core.
|
||||
// It keeps a generic seam instead, and the module fills it.
|
||||
//
|
||||
// **The namespace comes from the registration, not from the string.** A row's
|
||||
// `feature` is resolved by the provider its OWN module registered, so a module
|
||||
// author writes `feature: 'status'` exactly as it reads today: nothing parses a
|
||||
// prefix, and a typo'd namespace is not a thing that can exist. Core's own rows
|
||||
// carry no `moduleId` and resolve against the owner id `core`, which is what
|
||||
// core registers `useShardFeatures` under until Phase 3 moves those rows into
|
||||
// the module and they arrive stamped `uo` instead.
|
||||
//
|
||||
// Everything here fails OPEN, and that is deliberate and unchanged from
|
||||
// useShardFeatures' own posture: this is presentation, the gate is server-side
|
||||
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
|
||||
// rendered), so an unknown answer shows the link rather than blanking the nav.
|
||||
// The one thing a UI mistake must never do here is hide a page from someone
|
||||
// entitled to it.
|
||||
|
||||
/**
|
||||
* The predicate the layouts filter their nav with.
|
||||
*
|
||||
* @param {Map<string, {has: (name: string) => boolean} | null | undefined>} flagsByOwner
|
||||
* one entry per registered provider, keyed by the id of the module that
|
||||
* registered it. The value is whatever that provider's hook returned this
|
||||
* render: a Set-like of the flags this viewer may see, or `null` while the
|
||||
* answer is still in flight.
|
||||
* @returns {(item: object) => boolean}
|
||||
*/
|
||||
export function buildFeatureGate(flagsByOwner) {
|
||||
return function isVisible(item) {
|
||||
if (!item || !item.feature) return true
|
||||
const owner = item.moduleId ?? 'core'
|
||||
// No provider for this owner: the row names a flag nothing answers for. That
|
||||
// is the no-module-installed case — no core row carries a `feature` once the
|
||||
// module is out — and it is a correct no-op rather than a hidden row.
|
||||
if (!flagsByOwner || !flagsByOwner.has(owner)) return true
|
||||
const flags = flagsByOwner.get(owner)
|
||||
// Still loading, or a provider that returned something unusable. Both are
|
||||
// "we do not know yet", and both show the link.
|
||||
if (!flags || typeof flags.has !== 'function') return true
|
||||
return flags.has(item.feature)
|
||||
}
|
||||
}
|
||||
|
||||
/** The gate an area with no providers gets: everything is visible. */
|
||||
export const OPEN_GATE = () => true
|
||||
|
||||
export default buildFeatureGate
|
||||
@@ -1,65 +0,0 @@
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
import { featureProviders } from './registry.js'
|
||||
import { buildFeatureGate, OPEN_GATE } from './featureGate.js'
|
||||
|
||||
// The React half of the feature seam. The decision logic is featureGate.js,
|
||||
// which is plain JS and therefore testable in a runner with no DOM; this file is
|
||||
// wiring, the same split registry.js and shared.js already use.
|
||||
//
|
||||
// **Calling a hook per provider inside a loop is the point, and it is legal
|
||||
// here.** The rules of hooks require the same hooks in the same order on every
|
||||
// render of a component — not a statically known list. The provider list is
|
||||
// fixed before the first render (registration happens while module chunks
|
||||
// evaluate, and main.jsx does not mount until DOMContentLoaded), there is no
|
||||
// unregistering, and the snapshot below freezes it per component instance
|
||||
// anyway. So the loop's length cannot change between renders of this provider,
|
||||
// which is the actual requirement.
|
||||
//
|
||||
// A provider hook returns a Set-like of the flags this viewer may see, or `null`
|
||||
// while it is still fetching. Core knows nothing else about it: what a flag
|
||||
// means, how it is fetched, and what it is gated on are all the module's.
|
||||
|
||||
const FeatureGateContext = createContext(OPEN_GATE)
|
||||
|
||||
export function ModuleFeaturesProvider({ children }) {
|
||||
// Snapshotted once. useState's initialiser runs on the first render only, so
|
||||
// even a provider that somehow registered late cannot change this instance's
|
||||
// hook count mid-life — it would be ignored until the next mount, which is a
|
||||
// far better failure than a crashed render.
|
||||
const [providers] = useState(featureProviders)
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above
|
||||
const values = providers.map((provider) => provider.hook())
|
||||
|
||||
const gate = useMemo(
|
||||
() => {
|
||||
const byOwner = new Map()
|
||||
// First registration wins for a given owner: a module that registers two
|
||||
// namespaces answers its own nav rows from the first, rather than from
|
||||
// whichever happened to be stored last.
|
||||
providers.forEach((provider, i) => {
|
||||
if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i])
|
||||
})
|
||||
return buildFeatureGate(byOwner)
|
||||
},
|
||||
// One dependency per provider — a fixed-length list, for the same reason the
|
||||
// hook loop above is fixed-length.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[providers, ...values],
|
||||
)
|
||||
|
||||
return <FeatureGateContext.Provider value={gate}>{children}</FeatureGateContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* The predicate to filter nav rows with: `(item) => boolean`, true when the row
|
||||
* carries no `feature` or when its module says this viewer may see it.
|
||||
*
|
||||
* Outside a provider it is the open gate, so a component rendered in isolation
|
||||
* (a test, a preview) shows its whole nav rather than none of it.
|
||||
*/
|
||||
export function useFeatureGate() {
|
||||
return useContext(FeatureGateContext)
|
||||
}
|
||||
|
||||
export default ModuleFeaturesProvider
|
||||
@@ -1,174 +0,0 @@
|
||||
// The interleave of module nav items into core's nav.
|
||||
//
|
||||
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
|
||||
// the normative contract is docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// **Module items join the BASE array, before anything else happens to it.** That
|
||||
// is the whole design of this file and the override merge next door forces it:
|
||||
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
|
||||
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
|
||||
// route cannot leave a stale row doing something unexpected later). Append
|
||||
// module items *after* that merge and they are unreachable to Admin →
|
||||
// Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all
|
||||
// three of those things, so appending would make the extraction a visible
|
||||
// regression for every operator who has ever touched their nav.
|
||||
//
|
||||
// So the pipeline gains one step at the front and nothing else changes:
|
||||
//
|
||||
// withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
|
||||
//
|
||||
// and the filter stays last, which is what keeps it the boundary an override
|
||||
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
|
||||
// the other way round; the code is right and the contract was amended.
|
||||
//
|
||||
// The result is that a module row is, to everything downstream, an ordinary row.
|
||||
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.
|
||||
|
||||
import { navFor } from './registry.js'
|
||||
import { isGrouped } from '../lib/navOverrides.js'
|
||||
|
||||
// Rows with no group of their own are collected under this key. A Symbol rather
|
||||
// than a string so it cannot collide with a group an admin or a module names.
|
||||
const UNGROUPED = Symbol('ungrouped')
|
||||
|
||||
/**
|
||||
* Sort by effective position, where a row that asked for nothing keeps the index
|
||||
* it already had. Three tie-breaks, in this order: an explicit `order` beats a
|
||||
* coincidental index (the module said "third", so third), and two explicit
|
||||
* orders keep registration order, which `navFor` has already put in scan order.
|
||||
*
|
||||
* The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
|
||||
* admin who then drags that row is editing the position this produced.
|
||||
*/
|
||||
function place(entries) {
|
||||
return entries
|
||||
.map((entry, index) => ({ ...entry, index }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
|
||||
.map(({ item }) => item)
|
||||
}
|
||||
|
||||
function entryFor(item, fallbackKey) {
|
||||
return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
|
||||
}
|
||||
|
||||
function coreEntries(items) {
|
||||
return items.map((item, index) => ({ item, key: index, explicit: false }))
|
||||
}
|
||||
|
||||
/** The `to`s a base nav already claims, flat or grouped. */
|
||||
function claimedPaths(baseNav, grouped) {
|
||||
return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a module row whose `to` is already on the nav, and say so.
|
||||
*
|
||||
* Not a policy about where a module may link — it is that `to` is the KEY the
|
||||
* override layer stores under and React renders by. Two rows sharing one would
|
||||
* give an admin a single editor row that silently moves both, and a duplicate
|
||||
* key in the rendered list. Dropping the newcomer keeps core's row, which is the
|
||||
* one any existing override was written against.
|
||||
*
|
||||
* Fail-safe like every other read in this area: the offending row goes, its
|
||||
* neighbours stay.
|
||||
*/
|
||||
function withoutCollisions(items, claimed) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
if (!item || typeof item.to !== 'string' || !item.to) continue
|
||||
if (claimed.has(item.to)) {
|
||||
console.warn(
|
||||
`[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
claimed.add(item.to)
|
||||
out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The flat navs — the public header and the player portal.
|
||||
//
|
||||
// No groups, so `order` is a position in the one list: core rows are keyed by
|
||||
// their index and a module row by the `order` it asked for. A module row with no
|
||||
// order appends after the coded ones, in registration order, rather than jumping
|
||||
// to the front on a 0 default — the same choice buildPublicNav makes for an
|
||||
// admin-created link.
|
||||
function mergeFlat(baseNav, items) {
|
||||
return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
|
||||
}
|
||||
|
||||
// The grouped nav — the admin sidebar.
|
||||
//
|
||||
// `group` names an existing core group and the row lands inside it: Moderation
|
||||
// and System, where today's UO rows already sit (§1.4). An unknown group name
|
||||
// creates a group at the end rather than dropping the row — a typo must cost a
|
||||
// position, never a link. A row with no `group` at all lands in a trailing
|
||||
// untitled group, which renders as ungrouped links; core does not invent a
|
||||
// display title out of a module id.
|
||||
//
|
||||
// An ungrouped row is NOT folded into one of core's own untitled groups
|
||||
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
|
||||
// the sidebar, and a module page does not belong beside "Account".
|
||||
//
|
||||
// A group created here is a group as far as everything downstream is concerned,
|
||||
// including as a destination in Admin → Navigation's "move to section" control:
|
||||
// `readOverrides` builds its set of legal destinations from the base nav it is
|
||||
// handed, which is this one.
|
||||
function mergeGrouped(baseNav, items) {
|
||||
const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
|
||||
const into = new Map() // existing group title → rows
|
||||
const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order
|
||||
|
||||
for (const item of items) {
|
||||
const named = typeof item.group === 'string' && item.group ? item.group : null
|
||||
const key = named ?? UNGROUPED
|
||||
const bucket = named !== null && titles.has(named) ? into : fresh
|
||||
if (!bucket.has(key)) bucket.set(key, [])
|
||||
bucket.get(key).push(item)
|
||||
}
|
||||
|
||||
const kept = baseNav.map((g) => {
|
||||
const incoming = into.get(g.title)
|
||||
if (!incoming) return g
|
||||
return {
|
||||
...g,
|
||||
items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
|
||||
}
|
||||
})
|
||||
|
||||
const created = [...fresh.entries()].map(([key, rows]) => {
|
||||
const items_ = place(rows.map((item, i) => entryFor(item, i)))
|
||||
return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
|
||||
})
|
||||
|
||||
return [...kept, ...created]
|
||||
}
|
||||
|
||||
/**
|
||||
* The base nav a layout should render: core's coded array with every installed
|
||||
* module's rows for this area interleaved into it.
|
||||
*
|
||||
* Returns `baseNav` ITSELF when no module registered anything for this area, so
|
||||
* an instance with no modules installed renders the identical array it renders
|
||||
* today — the same "untouched path" guarantee applyNavOverrides makes, and what
|
||||
* makes a `useMemo` with an empty dependency list around this call honest.
|
||||
*
|
||||
* Safe to call once per component and cache: registration completes before the
|
||||
* first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
|
||||
* there is no unregistering, so this answer cannot change during a session.
|
||||
*
|
||||
* @param {Array} baseNav the coded NAV, flat or grouped
|
||||
* @param {'public'|'admin'|'player'} area
|
||||
* @returns {Array} a nav of the same shape
|
||||
*/
|
||||
export function withModuleNav(baseNav, area) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
const grouped = isGrouped(baseNav)
|
||||
const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
|
||||
if (items.length === 0) return baseNav
|
||||
return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
|
||||
}
|
||||
|
||||
export default withModuleNav
|
||||
@@ -1,37 +1,23 @@
|
||||
// ── The client-side module registry ────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
|
||||
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
|
||||
// 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.
|
||||
//
|
||||
// A module's prebuilt chunk registers its routes, its nav entries and its feature
|
||||
// provider here, and core reads them back. This is the client twin of the
|
||||
// server's modules/loader.js — with one structural difference worth stating,
|
||||
// because it is what makes the file this short: core *hands* the registry to the
|
||||
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
|
||||
// nothing to scan, nothing to validate a manifest against, and no failure mode
|
||||
// where half a module is registered.
|
||||
// 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.
|
||||
//
|
||||
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
|
||||
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
|
||||
// core's own bundle. Module scripts are deferred, so they evaluate after that
|
||||
// bundle has run — which is where `window.__rg` is published — and all of them
|
||||
// finish before DOMContentLoaded. main.jsx waits for that same event before
|
||||
// calling render(), so registration is complete before React reads any of this.
|
||||
//
|
||||
// That is what buys the simplicity here: registration is a plain synchronous
|
||||
// write with no subscribers, not an observable store, because nothing can
|
||||
// register after the first render. If that ever stops being true it changes in
|
||||
// this file and in main.jsx, not in a dozen consumers.
|
||||
//
|
||||
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
|
||||
// are stored and returned faithfully but core does not read them yet — PR 8 adds
|
||||
// the nav interleave and the feature-provider seam. Storing them is not the kind
|
||||
// of accepting stub the server's registries refused to be: nothing is discarded
|
||||
// here, so a module that registers nav in this core gets it back from `navFor`.
|
||||
// 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 providers = new Map()
|
||||
const featureProviders = new Map()
|
||||
const registered = new Set()
|
||||
|
||||
const AREAS = ['public', 'admin', 'player']
|
||||
@@ -41,26 +27,18 @@ function assertArea(area, call) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route components, by area.
|
||||
*
|
||||
* @param {string} id the module id — the URL segment its routes are namespaced under
|
||||
* 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; core prefixes it and mounts it inside the area's existing wrapper
|
||||
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
|
||||
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
|
||||
* `gate` is an optional `{ roles: [...] }` that core applies as its own
|
||||
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
|
||||
* the route table have to agree about who may see what (§3.3).
|
||||
* 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: a module cannot claim a path
|
||||
// outside its own namespace however it spells `path` — a leading `/`, a
|
||||
// trailing one, or several — because it never gets to write the segment
|
||||
// its routes hang under.
|
||||
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 })
|
||||
}
|
||||
@@ -69,14 +47,9 @@ export function registerRoutes(id, byArea) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav entries, interleaved into CORE groups rather than appended as a block.
|
||||
*
|
||||
* Today's UO items sit inside core's own Moderation and System groups; a "UO"
|
||||
* group at the bottom of the sidebar would be a visible regression on the day
|
||||
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
|
||||
* core group, `order` sorts within it, and an unknown group name appends rather
|
||||
* than dropping the item — a mis-typed group must cost a position, never a link.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
@@ -84,60 +57,38 @@ export function registerNav(id, spec) {
|
||||
const { area, items } = spec || {}
|
||||
assertArea(area, 'registerNav')
|
||||
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||
registered.add(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 — `uo` fills
|
||||
* its namespace with today's `useShardFeatures` (MODULE_SYSTEM.md §1.5). With no
|
||||
* 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.
|
||||
* carries a `feature` today (MODULE_SYSTEM.md §1.5).
|
||||
*/
|
||||
export function registerFeatureProvider(id, namespace, hook) {
|
||||
providers.set(namespace, { id, hook })
|
||||
registered.add(id)
|
||||
featureProviders.set(namespace, { id, hook })
|
||||
}
|
||||
|
||||
export const routesFor = (area) => routes[area] || []
|
||||
|
||||
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
|
||||
// this ships to, so two modules asking for the same slot keep load order —
|
||||
// which is alphabetical by id, the same order the server scans in (§4.2).
|
||||
// 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) => providers.get(namespace)
|
||||
|
||||
/**
|
||||
* Every registered provider, for core's feature context to call.
|
||||
*
|
||||
* Exported from the module but deliberately NOT a member of the `registry`
|
||||
* object below: a module asks for a namespace it knows the name of, and has no
|
||||
* business enumerating what everyone else registered. Core needs the list
|
||||
* because it has to call each hook — unconditionally, in a fixed order, at the
|
||||
* top of a component (modules/features.jsx).
|
||||
*/
|
||||
export const featureProviders = () =>
|
||||
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
|
||||
|
||||
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
|
||||
export const registeredIds = () => [...registered]
|
||||
|
||||
/** Test seam. Nothing in the app calls this — there is no unregistering. */
|
||||
// Test seam.
|
||||
export function _reset() {
|
||||
for (const area of AREAS) {
|
||||
routes[area].length = 0
|
||||
nav[area].length = 0
|
||||
}
|
||||
providers.clear()
|
||||
featureProviders.clear()
|
||||
registered.clear()
|
||||
}
|
||||
|
||||
// The object handed to modules on window.__rg.registry. Deliberately the write
|
||||
// calls plus the read ones: a module reading `routesFor` is how it finds out
|
||||
// another module is installed, which is the only supported form of module-to-
|
||||
// module awareness (there is no dependency resolution).
|
||||
export const registry = {
|
||||
registerRoutes,
|
||||
registerNav,
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
// ── window.__rg — the shared-dependency global ─────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
|
||||
// docs/website/MODULE_API.md §3.2.
|
||||
// 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.
|
||||
//
|
||||
// A module's client half is a PREBUILT ESM chunk — the operator never builds
|
||||
// anything (MODULE_SYSTEM.md §1.14) — 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 the policy forbids inline scripts outright. So the shared dependencies ride
|
||||
// on a global, and the module's Rollup externals are aliased to two-line shims
|
||||
// that re-export from it (§3.6).
|
||||
//
|
||||
// **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 its first
|
||||
// useState. That is the same rule the server half enforces for `express` and
|
||||
// `express-validator` on `ctx`, and for the same reason: anything shared between
|
||||
// core and a module is owned by core and HANDED OVER, never resolved by the
|
||||
// module.
|
||||
// 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, and it is not decoration. A module's bundler
|
||||
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
|
||||
// default, and those have to resolve to CORE's React like every other import.
|
||||
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
|
||||
// with it, a module uses the default its tooling already assumes.
|
||||
// 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'
|
||||
@@ -39,22 +30,10 @@ import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { request, ApiError } from '../api/client.js'
|
||||
|
||||
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
||||
// seven are what the smallest UO page already needs beyond React and the router:
|
||||
// without them a module either reaches into core's tree — violating the
|
||||
// zero-import rule the whole boundary rests on — or ships its own copies, which
|
||||
// means a module page that does not look like the site it is installed in, and
|
||||
// that drifts further every time core's layout changes.
|
||||
//
|
||||
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
|
||||
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
|
||||
// the price of the boundary being worth anything.
|
||||
//
|
||||
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
|
||||
// such component — admin views are plain markup inside AdminLayout — and
|
||||
// inventing one to satisfy a table would be a core change with no consumer until
|
||||
// Phase 3. The contract is amended rather than the code padded, and adding it
|
||||
// later costs a minor bump, which is exactly the case the versioning is for.
|
||||
// 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,
|
||||
@@ -66,21 +45,12 @@ const ui = {
|
||||
useSite,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the `api` object (§3.5). `api.atlas` and `api.shard`
|
||||
// are module bindings that only still live in core's client because Phase 3 has
|
||||
// not moved them; a module builds its own namespace over `request` and owns the
|
||||
// paths it calls — which is right, because it owns the routes at the other end.
|
||||
// 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 }
|
||||
|
||||
/**
|
||||
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
|
||||
* module chunk evaluates.
|
||||
*
|
||||
* Frozen, one level down as well as at the top: the object a module reaches for
|
||||
* its React is not somewhere a module gets to leave something for the next one.
|
||||
* Cross-module communication is a thing the contract does not have, and an
|
||||
* unfrozen global is how a codebase acquires one by accident.
|
||||
*/
|
||||
export function publishSharedDependencies() {
|
||||
window.__rg = Object.freeze({
|
||||
version: MODULE_API_VERSION,
|
||||
@@ -92,5 +62,4 @@ export function publishSharedDependencies() {
|
||||
ui: Object.freeze(ui),
|
||||
api: Object.freeze(api),
|
||||
})
|
||||
return window.__rg
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// The client's copy of MODULE_API_VERSION. It must equal the server's
|
||||
// (server/src/modules/version.js) — the two halves version ONE contract
|
||||
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
|
||||
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
|
||||
// against the client's before it registers anything.
|
||||
// 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, and that is deliberate. The value has to be on
|
||||
// `window.__rg` before the first module chunk evaluates, which is earlier than
|
||||
// any network round trip could answer — a fetched version would mean either an
|
||||
// await before render or a module reading `undefined`. The cost of the copy is
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 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'
|
||||
|
||||
@@ -6,9 +6,6 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
import { withModuleNav } from '../../modules/nav.js'
|
||||
import { useFeatureGate } from '../../modules/features.jsx'
|
||||
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
|
||||
|
||||
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||
@@ -107,6 +104,10 @@ export const NAV = [
|
||||
|
||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
|
||||
// The one row an override may never hide: the nav editor itself, which is the
|
||||
// only screen that can un-hide anything. The write path already refuses it
|
||||
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
|
||||
@@ -122,11 +123,15 @@ function keepEditorReachable(overrides) {
|
||||
return { ...overrides, [UNHIDEABLE]: rest }
|
||||
}
|
||||
|
||||
// Who may see a sidebar row, and where that lets them go, both derived from the
|
||||
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
|
||||
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
|
||||
// because Admin -> Navigation has always imported it from here.
|
||||
export { navItemVisibleTo }
|
||||
// Who may see a sidebar row. The single authority for that question: the layout
|
||||
// applies it after the override merge (overrides are presentation, this is the
|
||||
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
|
||||
// admin is never offered a row they cannot themselves see (§8.1).
|
||||
export function navItemVisibleTo(item, role) {
|
||||
if (item.roles && !item.roles.includes(role)) return false
|
||||
if (role === 'moderator') return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
|
||||
const TITLES = {
|
||||
'/admin': 'Dashboard',
|
||||
@@ -154,19 +159,6 @@ const TITLES = {
|
||||
'/admin/account': 'Account Security',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
// not know what they are called. Their nav row does, so the row is the title:
|
||||
// the longest matching module row wins, so a detail page under a section titles
|
||||
// as that section rather than falling through to a bare "Admin". Restricted to
|
||||
// rows a module registered, which is what keeps every core path resolving
|
||||
// through TITLES and sectionTitle exactly as it does today.
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
return baseNav
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
|
||||
.sort((a, b) => b.to.length - a.to.length)[0]?.label
|
||||
}
|
||||
|
||||
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
|
||||
function sectionTitle(pathname) {
|
||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||
@@ -194,15 +186,7 @@ export default function AdminLayout() {
|
||||
const navOverrides = useNavOverrides()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isVisible = useFeatureGate()
|
||||
|
||||
// Core's rows plus every installed module's, before the override merge sees
|
||||
// them — so a module row is editable in Admin -> Navigation like any other
|
||||
// (modules/nav.js). Computed once: the registry is fixed before the first
|
||||
// render and nothing unregisters.
|
||||
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
|
||||
const title =
|
||||
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
|
||||
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
|
||||
// The hero canvas editor needs room — let it use the full content width.
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||
@@ -216,17 +200,11 @@ export default function AdminLayout() {
|
||||
// NAV itself and this is exactly the code that ran before the feature.
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
|
||||
.map((g) => ({
|
||||
...g,
|
||||
// `isVisible` is a no-op for every core row — none carries a `feature`
|
||||
// — and is applied here so that a module row which does carry one is
|
||||
// gated on the sidebar rather than silently advertised.
|
||||
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
|
||||
}))
|
||||
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
|
||||
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
|
||||
// Drop any now-empty group so an empty category header never renders.
|
||||
.filter((g) => g.items.length > 0),
|
||||
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
|
||||
[navOverrides.nav_admin, user?.role],
|
||||
)
|
||||
|
||||
// Accordion: track which titled categories are collapsed. Persist across
|
||||
@@ -253,22 +231,17 @@ export default function AdminLayout() {
|
||||
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||
)?.title
|
||||
|
||||
// Where a moderator may go, from the same `roles` that decide what they see.
|
||||
// It used to be a third hardcoded list — a prefix check over three paths —
|
||||
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
|
||||
// was on the sidebar and not in the redirect, so a moderator who clicked
|
||||
// Houses in their own nav was bounced straight back to Moderation. One
|
||||
// derivation cannot disagree with itself, which is the point of deriving it.
|
||||
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
|
||||
|
||||
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||
useEffect(() => {
|
||||
if (!isModerator) return
|
||||
if (!isAllowedPath(location.pathname, allowed)) {
|
||||
const p = location.pathname
|
||||
const allowed =
|
||||
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
|
||||
if (!allowed) {
|
||||
navigate('/admin/moderation', { replace: true })
|
||||
}
|
||||
}, [isModerator, location.pathname, navigate, allowed])
|
||||
}, [isModerator, location.pathname, navigate])
|
||||
|
||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,8 +13,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { withModuleNav } from '../../../modules/nav.js'
|
||||
import { useFeatureGate } from '../../../modules/features.jsx'
|
||||
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
|
||||
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
|
||||
import PublicNavTree from './PublicNavTree.jsx'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
@@ -245,7 +244,7 @@ export function Row({ row, id, destinations, destination, onDestination, onChang
|
||||
export default function NavEditor() {
|
||||
const { user } = useAuth()
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const isVisible = useFeatureGate()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const [tab, setTab] = useState('nav_public')
|
||||
// Per nav: the editable groups, the overrides as loaded (so a row this admin
|
||||
// cannot see survives their save), and whether a settings row exists at all.
|
||||
@@ -256,39 +255,25 @@ export default function NavEditor() {
|
||||
const [saved, setSaved] = useState('')
|
||||
const [dirty, setDirty] = useState({})
|
||||
|
||||
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
|
||||
// public nav's gates are the shard-feature ones; the admin nav's are roles.
|
||||
// The player portal has no gates at all.
|
||||
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
|
||||
// this is what still EXISTS, and the two are different questions. Saving needs
|
||||
// both: an entry for a row their palette filtered out must be carried through
|
||||
// rather than reset, and only an entry for a route the code no longer declares
|
||||
// at all should be dropped.
|
||||
//
|
||||
// Each nav is the coded array with every installed module's rows already
|
||||
// interleaved (modules/nav.js) — the same array the layout renders, which is
|
||||
// what makes a module row editable here at all: the override merge is keyed by
|
||||
// `to` and drops a key the base it is handed does not declare, so a nav built
|
||||
// from core alone would silently discard every stored override on a module row
|
||||
// the moment it was saved.
|
||||
const fullNavs = useMemo(
|
||||
() => ({
|
||||
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
|
||||
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
|
||||
nav_player: withModuleNav(PLAYER_NAV, 'player'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
|
||||
|
||||
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
|
||||
// gates, and neither is core's own opinion any more — `roles` on a row, and
|
||||
// the owning module's answer for a row that names a `feature`.
|
||||
const palettes = useMemo(
|
||||
() => ({
|
||||
nav_public: fullNavs.nav_public.filter(isVisible),
|
||||
nav_admin: fullNavs.nav_admin
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
|
||||
.filter((g) => g.items.length > 0),
|
||||
nav_player: fullNavs.nav_player.filter(isVisible),
|
||||
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
|
||||
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
|
||||
(g) => g.items.length > 0,
|
||||
),
|
||||
nav_player: PLAYER_NAV,
|
||||
}),
|
||||
[fullNavs, isVisible, user?.role],
|
||||
[shardFeatures, user?.role],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -177,15 +177,14 @@ const delStyle = {
|
||||
}
|
||||
|
||||
// ── Announcement status panel ────────────────────────────────────────────────
|
||||
// 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).
|
||||
// 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' },
|
||||
}
|
||||
const STATUS_STYLE = {
|
||||
done: { color: '#7bbf8f', label: 'delivered' },
|
||||
pending: { color: '#d9b84a', label: 'pending' },
|
||||
@@ -228,12 +227,14 @@ function AnnouncePanel({ postId }) {
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
|
||||
{['towncrier', 'discord'].map((leg) => {
|
||||
const status = job[`${leg}_status`]
|
||||
const err = job[`${leg}_last_error`]
|
||||
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 }}>{label}</span>
|
||||
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
|
||||
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
||||
{status !== 'done' && (
|
||||
<button
|
||||
|
||||
@@ -6,8 +6,6 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
import { withModuleNav } from '../../modules/nav.js'
|
||||
import { useFeatureGate } from '../../modules/features.jsx'
|
||||
|
||||
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
||||
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
||||
@@ -37,10 +35,9 @@ const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||
// carries a gate — every player sees all three — but an installed module's rows
|
||||
// join this list before the merge and may carry a `feature`, so the filter after
|
||||
// it is not dead code.
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No row
|
||||
// carries a gate — every player sees all three — so the merged result is what
|
||||
// renders, with no filter after it.
|
||||
export const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
@@ -72,12 +69,7 @@ export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const isVisible = useFeatureGate()
|
||||
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
|
||||
const nav = useMemo(
|
||||
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
|
||||
[baseNav, navOverrides.nav_player, isVisible],
|
||||
)
|
||||
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../src/lib/adminNav.js'
|
||||
|
||||
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
|
||||
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
|
||||
// drifted apart from each other, so the tests worth having are the ones that
|
||||
// pin what a moderator may now see and reach, and the shape of the match.
|
||||
|
||||
// The real sidebar, trimmed to the rows that decide something here.
|
||||
const NAV = [
|
||||
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
|
||||
{
|
||||
title: 'Moderation',
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/moderation/appeals', label: 'Appeals', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/shard-ops', label: 'In-Game Ops', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/houses', label: 'Houses', roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
|
||||
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin/characters', label: 'My Characters' },
|
||||
{ to: '/admin/account', label: 'Account' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleTo = (role) =>
|
||||
NAV.flatMap((g) => g.items)
|
||||
.filter((i) => navItemVisibleTo(i, role))
|
||||
.map((i) => i.to)
|
||||
|
||||
test('a row with no roles is visible to every staff role', () => {
|
||||
// Self-service: staff are a superset of players, so a moderator reaching their
|
||||
// own characters is not a privilege, it is the thing every account has.
|
||||
for (const role of ['admin', 'editor', 'moderator']) {
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/account' }, role), true)
|
||||
}
|
||||
})
|
||||
|
||||
test('a role not named on the row cannot see it', () => {
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'moderator'), false)
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'admin'), true)
|
||||
// An unknown or absent role sees only the ungated rows.
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, undefined), false)
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/account' }, undefined), true)
|
||||
})
|
||||
|
||||
test('what a moderator sees is exactly the moderation section, plus self-service', () => {
|
||||
// The two additions the derivation makes over the old MOD_PATHS list are
|
||||
// Dashboard — whose roles have always named moderator, so the two lists
|
||||
// disagreed — and My Characters. Both are already permitted server-side.
|
||||
assert.deepEqual(visibleTo('moderator'), [
|
||||
'/admin',
|
||||
'/admin/moderation',
|
||||
'/admin/moderation/appeals',
|
||||
'/admin/shard-ops',
|
||||
'/admin/houses',
|
||||
'/admin/characters',
|
||||
'/admin/account',
|
||||
])
|
||||
})
|
||||
|
||||
test('an admin still sees everything and an editor still sees nothing extra', () => {
|
||||
assert.equal(visibleTo('admin').length, NAV.flatMap((g) => g.items).length)
|
||||
assert.deepEqual(visibleTo('editor'), ['/admin', '/admin/characters', '/admin/account'])
|
||||
})
|
||||
|
||||
test('a row with `end` matches exactly — the dashboard is not a prefix', () => {
|
||||
// The bug this shape exists to prevent: treating `/admin` as a prefix would
|
||||
// make every path in the admin area allowed for anyone who can see Dashboard.
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin', allowed), true)
|
||||
assert.equal(isAllowedPath('/admin/users', allowed), false)
|
||||
assert.equal(isAllowedPath('/admin/users/12', allowed), false)
|
||||
})
|
||||
|
||||
test('every other row covers its own sub-routes', () => {
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin/moderation/appeals/12', allowed), true)
|
||||
assert.equal(isAllowedPath('/admin/characters/0x4001', allowed), true)
|
||||
})
|
||||
|
||||
test('a sibling path that merely shares a prefix is NOT covered', () => {
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
// `/admin/houses-secret` starts with `/admin/houses` as a string; the match is
|
||||
// on path segments, so it does not start with `/admin/houses/`.
|
||||
assert.equal(isAllowedPath('/admin/houses-secret', allowed), false)
|
||||
assert.equal(isAllowedPath('/admin/houses/42', allowed), true)
|
||||
})
|
||||
|
||||
test('Houses is reachable, which is the defect the derivation fixed', () => {
|
||||
// The redirect used to allow only /admin/moderation*, /admin/shard-ops* and
|
||||
// /admin/account, while the sidebar showed Houses — so a moderator clicking a
|
||||
// row in their own nav was bounced back to Moderation.
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin/houses', allowed), true)
|
||||
})
|
||||
|
||||
test('a module row a moderator may see is reachable without core listing it', () => {
|
||||
// The reason this is derived at all: core cannot hardcode a path it has never
|
||||
// heard of, and a module row arrives with `roles` like any other.
|
||||
const withModule = [
|
||||
...NAV,
|
||||
{ title: 'Shard', items: [{ to: '/admin/uo/shard-ops', label: 'Ops', roles: ['admin', 'moderator'], moduleId: 'uo' }] },
|
||||
]
|
||||
const allowed = allowedPathsFor(withModule, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin/uo/shard-ops', allowed), true)
|
||||
assert.equal(isAllowedPath('/admin/uo/shard-ops/queue', allowed), true)
|
||||
})
|
||||
|
||||
test('a nav that is not there does not throw', () => {
|
||||
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
|
||||
assert.equal(isAllowedPath('/admin', undefined), false)
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { buildFeatureGate, OPEN_GATE } from '../src/modules/featureGate.js'
|
||||
|
||||
// The feature seam's decision logic (MODULE_SYSTEM.md §1.5, MODULE_API.md §3.3).
|
||||
// Every branch here fails OPEN, and that is the property under test as much as
|
||||
// the happy path: this is presentation, the server is the gate, and a UI mistake
|
||||
// that hides a page from someone entitled to it is worse in every case than one
|
||||
// that shows a link which then 403s.
|
||||
|
||||
const flags = (...names) => new Set(names)
|
||||
|
||||
test('a row with no feature is always visible', () => {
|
||||
const gate = buildFeatureGate(new Map([['uo', flags()]]))
|
||||
assert.equal(gate({ to: '/site/news' }), true)
|
||||
})
|
||||
|
||||
test('a core row resolves against the owner id `core`', () => {
|
||||
// Core's ten shard-gated rows carry no moduleId, and core registers its
|
||||
// provider under `core` (main.jsx) precisely so they resolve without one.
|
||||
const gate = buildFeatureGate(new Map([['core', flags('atlas')]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
assert.equal(gate({ to: '/site/market', feature: 'market' }), false)
|
||||
})
|
||||
|
||||
test('a module row resolves against ITS module, not another one', () => {
|
||||
const gate = buildFeatureGate(
|
||||
new Map([
|
||||
['uo', flags('atlas')],
|
||||
['rust', flags('market')],
|
||||
]),
|
||||
)
|
||||
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
|
||||
// `market` is a flag the OTHER module grants. Resolution is by registration,
|
||||
// so there is no string a module can write to borrow it.
|
||||
assert.equal(gate({ to: '/uo/market', feature: 'market', moduleId: 'uo' }), false)
|
||||
assert.equal(gate({ to: '/rust/market', feature: 'market', moduleId: 'rust' }), true)
|
||||
})
|
||||
|
||||
test('no provider for the owner shows the row', () => {
|
||||
// The no-module-installed case, and the reason the filter is a correct no-op
|
||||
// on a bare core rather than a nav that renders nothing.
|
||||
const gate = buildFeatureGate(new Map())
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
|
||||
})
|
||||
|
||||
test('a provider still loading shows the row', () => {
|
||||
// useShardFlags returns null until its fetch lands. Blanking the nav on every
|
||||
// page load and filling it in a moment later is the behaviour this avoids.
|
||||
const gate = buildFeatureGate(new Map([['core', null]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
})
|
||||
|
||||
test('a provider that returned something unusable shows the row', () => {
|
||||
for (const bad of [undefined, 42, 'atlas', {}, []]) {
|
||||
const gate = buildFeatureGate(new Map([['core', bad]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true, `failed closed on ${JSON.stringify(bad)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an array-backed provider is not silently treated as a Set', () => {
|
||||
// `[].has` does not exist, so this is the unusable case above rather than a
|
||||
// membership test that quietly always fails. Asserted so that a future
|
||||
// "helpful" normalisation knows it changed a documented behaviour.
|
||||
const gate = buildFeatureGate(new Map([['core', ['atlas']]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
})
|
||||
|
||||
test('a missing map, or a junk row, shows rather than throws', () => {
|
||||
assert.equal(buildFeatureGate(null)({ feature: 'atlas' }), true)
|
||||
assert.equal(buildFeatureGate(new Map())(null), true)
|
||||
assert.equal(buildFeatureGate(new Map())(undefined), true)
|
||||
})
|
||||
|
||||
test('any Set-like satisfies a provider — core does not require a Set', () => {
|
||||
const gate = buildFeatureGate(new Map([['uo', { has: (name) => name === 'ruleset' }]]))
|
||||
assert.equal(gate({ feature: 'ruleset', moduleId: 'uo' }), true)
|
||||
assert.equal(gate({ feature: 'champs', moduleId: 'uo' }), false)
|
||||
})
|
||||
|
||||
test('the open gate is what a component outside the provider gets', () => {
|
||||
assert.equal(OPEN_GATE({ feature: 'anything' }), true)
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { withModuleNav } from '../src/modules/nav.js'
|
||||
import { registerNav, _reset } from '../src/modules/registry.js'
|
||||
import { applyNavOverrides, buildPublicNav } from '../src/lib/navOverrides.js'
|
||||
|
||||
// The interleave of module nav rows into core's nav (MODULE_API.md §3.3, Phase 2
|
||||
// PR 8). Tested against the real merge next door rather than in isolation,
|
||||
// because the property that matters is a relationship between the two: a module
|
||||
// row has to be indistinguishable from a core row to everything downstream, and
|
||||
// the way to prove that is to run the downstream thing on it.
|
||||
|
||||
const PUBLIC = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
const ADMIN = [
|
||||
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'moderator'] }] },
|
||||
{ title: 'Moderation', items: [{ to: '/admin/moderation', label: 'Moderation' }] },
|
||||
{ title: 'System', items: [{ to: '/admin/users', label: 'Users' }, { to: '/admin/settings', label: 'Settings' }] },
|
||||
{ items: [{ to: '/admin/account', label: 'Account' }] },
|
||||
]
|
||||
|
||||
beforeEach(() => _reset())
|
||||
|
||||
test('with no module installed the base array is returned unchanged', () => {
|
||||
// Identity, not a copy: this is what makes the useMemo in each layout honest,
|
||||
// and what guarantees an instance with no modules renders what it renders now.
|
||||
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
||||
assert.equal(withModuleNav(ADMIN, 'admin'), ADMIN)
|
||||
})
|
||||
|
||||
test('a flat nav places a module row by the order it asked for', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 1 }] })
|
||||
assert.deepEqual(
|
||||
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
||||
['Home', 'Atlas', 'News', 'About'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a flat row with no order appends rather than jumping to the front', () => {
|
||||
// The 0-default trap: `order ?? 0` would put an unordered row first, which is
|
||||
// the one place a module could take over the nav without asking for anything.
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
||||
assert.deepEqual(
|
||||
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
||||
['Home', 'News', 'About', 'Atlas'],
|
||||
)
|
||||
})
|
||||
|
||||
test('an explicit order beats a core row that merely sits at that index', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 2 }] })
|
||||
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
||||
assert.deepEqual(labels, ['Home', 'News', 'Atlas', 'About'])
|
||||
})
|
||||
|
||||
test('an admin row lands INSIDE the core group it names', () => {
|
||||
registerNav('uo', {
|
||||
area: 'admin',
|
||||
items: [
|
||||
{ label: 'In-Game Ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30 },
|
||||
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 0 },
|
||||
],
|
||||
})
|
||||
const nav = withModuleNav(ADMIN, 'admin')
|
||||
assert.deepEqual(nav.map((g) => g.title), [undefined, 'Moderation', 'System', undefined])
|
||||
assert.deepEqual(nav[1].items.map((i) => i.label), ['Moderation', 'In-Game Ops'])
|
||||
// order 0 puts it above both core rows, which is the whole point of the field.
|
||||
assert.deepEqual(nav[2].items.map((i) => i.label), ['Shard', 'Users', 'Settings'])
|
||||
})
|
||||
|
||||
test('an unknown group appends a new group instead of dropping the row', () => {
|
||||
// A typo must cost a position, never a link.
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton' }] })
|
||||
const nav = withModuleNav(ADMIN, 'admin')
|
||||
assert.equal(nav.length, ADMIN.length + 1)
|
||||
assert.deepEqual(nav.at(-1), { title: 'Moderaton', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton', moduleId: 'uo' }] })
|
||||
})
|
||||
|
||||
test('an admin row with no group gets a trailing untitled group of its own', () => {
|
||||
// NOT folded into one of core's untitled groups: those are Dashboard at the
|
||||
// top and Account at the bottom, and a module page belongs beside neither.
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas' }] })
|
||||
const nav = withModuleNav(ADMIN, 'admin')
|
||||
assert.equal(nav.length, ADMIN.length + 1)
|
||||
assert.equal(nav.at(-1).title, undefined)
|
||||
assert.deepEqual(nav.at(-1).items.map((i) => i.label), ['Atlas'])
|
||||
assert.deepEqual(nav[0].items.map((i) => i.label), ['Dashboard'])
|
||||
assert.deepEqual(nav[3].items.map((i) => i.label), ['Account'])
|
||||
})
|
||||
|
||||
test('a row whose `to` collides with a core row is dropped, not rendered twice', () => {
|
||||
// `to` is the key the override layer stores under and React renders by. Two
|
||||
// rows sharing one would give an admin a single editor row that moves both.
|
||||
const warnings = []
|
||||
const warn = console.warn
|
||||
console.warn = (msg) => warnings.push(msg)
|
||||
try {
|
||||
registerNav('uo', {
|
||||
area: 'public',
|
||||
items: [{ label: 'Not News', to: '/site/news' }, { label: 'Atlas', to: '/uo/atlas' }],
|
||||
})
|
||||
const nav = withModuleNav(PUBLIC, 'public')
|
||||
assert.deepEqual(nav.map((i) => i.label), ['Home', 'News', 'About', 'Atlas'])
|
||||
assert.equal(warnings.length, 1)
|
||||
assert.match(warnings[0], /\/site\/news.*collides/)
|
||||
} finally {
|
||||
console.warn = warn
|
||||
}
|
||||
})
|
||||
|
||||
test('two modules cannot claim the same path either', () => {
|
||||
const warn = console.warn
|
||||
console.warn = () => {}
|
||||
try {
|
||||
registerNav('aa', { area: 'public', items: [{ label: 'First', to: '/shared' }] })
|
||||
registerNav('zz', { area: 'public', items: [{ label: 'Second', to: '/shared' }] })
|
||||
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
||||
assert.deepEqual(labels, ['Home', 'News', 'About', 'First'])
|
||||
} finally {
|
||||
console.warn = warn
|
||||
}
|
||||
})
|
||||
|
||||
test('a module row carries its moduleId through, which is how the gate finds it', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }] })
|
||||
const row = withModuleNav(PUBLIC, 'public').at(-1)
|
||||
assert.equal(row.moduleId, 'uo')
|
||||
assert.equal(row.feature, 'atlas')
|
||||
})
|
||||
|
||||
test('areas do not leak into one another', () => {
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
||||
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
||||
})
|
||||
|
||||
// ── The relationship that is the actual requirement ───────────────────────
|
||||
|
||||
test('an admin override applies to a module row exactly as to a core row', () => {
|
||||
// The reason the interleave happens BEFORE the merge and not after: the merge
|
||||
// drops any key its base array does not declare, so appending module rows
|
||||
// afterwards would make every one of them unorderable, unrelabellable and
|
||||
// unhideable — a visible regression the day the UO rows leave core.
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
||||
const base = withModuleNav(PUBLIC, 'public')
|
||||
const merged = applyNavOverrides(base, {
|
||||
'/uo/atlas': { label: 'Bestiary', order: 0 },
|
||||
'/site/news': { order: 3 },
|
||||
})
|
||||
assert.deepEqual(merged.map((i) => i.label), ['Bestiary', 'Home', 'About', 'News'])
|
||||
})
|
||||
|
||||
test('an override can hide a module row, and the public tree can section it', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }, { label: 'Market', to: '/uo/market' }] })
|
||||
const base = withModuleNav(PUBLIC, 'public')
|
||||
|
||||
const hidden = buildPublicNav(base, { '/uo/atlas': { hidden: true } })
|
||||
assert.equal(hidden.some((n) => n.to === '/uo/atlas'), false)
|
||||
|
||||
const sectioned = buildPublicNav(base, {
|
||||
items: { '/uo/market': { section: 'sec_shard' } },
|
||||
sections: [{ id: 'sec_shard', label: 'Shard', order: 0 }],
|
||||
})
|
||||
assert.equal(sectioned[0].kind, 'section')
|
||||
assert.deepEqual(sectioned[0].items.map((i) => i.to), ['/uo/market'])
|
||||
})
|
||||
|
||||
test('a module row can be moved between admin groups by an override', () => {
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
||||
const base = withModuleNav(ADMIN, 'admin')
|
||||
const merged = applyNavOverrides(base, { '/admin/uo/link': { group: 'Moderation' } })
|
||||
assert.deepEqual(merged[1].items.map((i) => i.to), ['/admin/moderation', '/admin/uo/link'])
|
||||
assert.deepEqual(merged[2].items.map((i) => i.to), ['/admin/users', '/admin/settings'])
|
||||
})
|
||||
|
||||
test('a group a module created is itself a legal override destination', () => {
|
||||
// Falls out of building the destination set from the base nav it is handed —
|
||||
// recorded because it is the kind of thing that would otherwise be discovered
|
||||
// by an admin finding a section they cannot move anything into.
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Shard' }] })
|
||||
const base = withModuleNav(ADMIN, 'admin')
|
||||
const merged = applyNavOverrides(base, { '/admin/users': { group: 'Shard' } })
|
||||
assert.deepEqual(merged.at(-1).items.map((i) => i.to), ['/admin/uo/atlas', '/admin/users'])
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
registry,
|
||||
registerRoutes,
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
featureProviders,
|
||||
registeredIds,
|
||||
_reset,
|
||||
} from '../src/modules/registry.js'
|
||||
import { MODULE_API_VERSION } from '../src/modules/version.js'
|
||||
|
||||
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
|
||||
// isolation from React, like the nav-override merge next door, because the
|
||||
// property worth proving has nothing to do with rendering: a module gets exactly
|
||||
// the URL namespace core gave it, however it spells the paths it registers.
|
||||
//
|
||||
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
|
||||
// there is no DOM in this runner. What it publishes is React, the router and
|
||||
// core components: a wiring test would assert that an import statement imported
|
||||
// something. Phase 1's spike proved the half that can actually fail, which is a
|
||||
// real chunk resolving its externals against the global in a browser under an
|
||||
// enforced CSP.
|
||||
|
||||
beforeEach(() => _reset())
|
||||
|
||||
test('a module route is namespaced under the module id', () => {
|
||||
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
|
||||
assert.deepEqual(
|
||||
routesFor('public').map((r) => r.path),
|
||||
['uo/atlas'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a module cannot spell its way out of its namespace', () => {
|
||||
// Whatever the module writes, the segment it lands under is core's to choose:
|
||||
// leading slashes, several of them, a trailing one, or nothing at all.
|
||||
registerRoutes('uo', {
|
||||
public: [
|
||||
{ path: '/atlas' },
|
||||
{ path: '//atlas/creatures' },
|
||||
{ path: 'atlas/' },
|
||||
{ path: '' },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(
|
||||
routesFor('public').map((r) => r.path),
|
||||
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
|
||||
// `..` is not stripped, and does not need to be: React Router matches path
|
||||
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
|
||||
// than a route that resolves somewhere else. Asserted so that a future
|
||||
// "cleanup" that starts resolving these knows it changed a behaviour.
|
||||
registerRoutes('uo', { public: [{ path: '../admin' }] })
|
||||
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
|
||||
})
|
||||
|
||||
test('routes keep their gate and carry the owning module id', () => {
|
||||
registerRoutes('uo', {
|
||||
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
|
||||
})
|
||||
const [route] = routesFor('admin')
|
||||
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
|
||||
assert.equal(route.moduleId, 'uo')
|
||||
assert.equal(route.element, 'OPS')
|
||||
})
|
||||
|
||||
test('the three areas are kept apart', () => {
|
||||
registerRoutes('uo', {
|
||||
public: [{ path: 'atlas' }],
|
||||
admin: [{ path: 'link' }],
|
||||
player: [{ path: 'chars' }],
|
||||
})
|
||||
assert.equal(routesFor('public').length, 1)
|
||||
assert.equal(routesFor('admin').length, 1)
|
||||
assert.equal(routesFor('player').length, 1)
|
||||
// An area nobody registered is an empty list, never undefined: App.jsx maps
|
||||
// over all three unconditionally.
|
||||
_reset()
|
||||
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
|
||||
})
|
||||
|
||||
test('an unknown area throws rather than being dropped', () => {
|
||||
// Loudly, because the alternative is a module whose pages simply never appear
|
||||
// and no indication anywhere of why.
|
||||
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
|
||||
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
|
||||
assert.equal(registeredIds().length, 0)
|
||||
})
|
||||
|
||||
test('nav items sort by order, and equal orders keep load order', () => {
|
||||
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
|
||||
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
|
||||
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
|
||||
assert.deepEqual(
|
||||
navFor('admin').map((i) => i.label),
|
||||
['First', 'Second', 'Third'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a nav item with no order sorts after the ones that asked for a place', () => {
|
||||
registerNav('uo', {
|
||||
area: 'public',
|
||||
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
|
||||
})
|
||||
assert.deepEqual(
|
||||
navFor('public').map((i) => i.label),
|
||||
['Early', 'Unordered'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a feature provider is stored under its namespace, with its owner', () => {
|
||||
const hook = () => ({ atlas: true })
|
||||
registerFeatureProvider('uo', 'shard', hook)
|
||||
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
|
||||
assert.equal(featureProviderFor('nothing'), undefined)
|
||||
})
|
||||
|
||||
test('providers can be enumerated in registration order, with their owner', () => {
|
||||
// Core's feature context has to CALL each of these, as a hook, in a fixed
|
||||
// order — so it needs the list, and it needs the owner id to resolve a nav
|
||||
// row whose `moduleId` says who it belongs to (modules/features.jsx).
|
||||
const uo = () => null
|
||||
const rust = () => null
|
||||
registerFeatureProvider('uo', 'shard', uo)
|
||||
registerFeatureProvider('rust', 'server', rust)
|
||||
assert.deepEqual(featureProviders(), [
|
||||
{ id: 'uo', namespace: 'shard', hook: uo },
|
||||
{ id: 'rust', namespace: 'server', hook: rust },
|
||||
])
|
||||
})
|
||||
|
||||
test('enumerating providers is NOT part of the module-facing surface', () => {
|
||||
// A module asks for a namespace it knows the name of; enumerating what
|
||||
// everyone else registered is core's business, so `featureProviders` is a
|
||||
// module export and not a member of window.__rg.registry.
|
||||
assert.equal(registry.featureProviders, undefined)
|
||||
assert.equal(typeof featureProviders, 'function')
|
||||
})
|
||||
|
||||
test('every registration marks the module registered', () => {
|
||||
registerRoutes('a', { public: [{ path: 'x' }] })
|
||||
registerNav('b', { area: 'public', items: [] })
|
||||
registerFeatureProvider('c', 'ns', () => {})
|
||||
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test('the registry object handed to modules exposes the whole surface', () => {
|
||||
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||
// member missing from the object is a member that does not exist.
|
||||
assert.deepEqual(Object.keys(registry).sort(), [
|
||||
'featureProviderFor',
|
||||
'navFor',
|
||||
'registerFeatureProvider',
|
||||
'registerNav',
|
||||
'registerRoutes',
|
||||
'registeredIds',
|
||||
'routesFor',
|
||||
])
|
||||
})
|
||||
|
||||
test('the client and server halves declare the same MODULE_API_VERSION', () => {
|
||||
// The value is duplicated because it has to be on window.__rg before the first
|
||||
// module chunk evaluates, which is earlier than a fetch could answer. This is
|
||||
// the test that pays for the copy: a bump that edits one file fails here
|
||||
// instead of shipping a core whose two halves disagree about the contract they
|
||||
// implement.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const server = fs.readFileSync(
|
||||
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
|
||||
'utf8',
|
||||
)
|
||||
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
|
||||
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
|
||||
assert.equal(MODULE_API_VERSION, match[1])
|
||||
})
|
||||
50
modules/uo/SPIKE.md
Normal file
50
modules/uo/SPIKE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# module-uo — the Phase 1 spike
|
||||
|
||||
**This branch is evidence, not implementation.** `spike/module-atlas` is cut from `edge` and is
|
||||
never merged. Phase 2 rebuilds the loader properly, with the `installed_modules` table, the full
|
||||
state machine and the admin panel behind it; Phase 3 does the real extraction.
|
||||
|
||||
What it demonstrates, and the results, are written up in
|
||||
[`docs/website/MODULE_API.md`](../../../docs/website/MODULE_API.md) Part 7. In one line: the six
|
||||
public spawn-atlas routes now live in a module, at byte-identical URLs, with the client half loading
|
||||
as a prebuilt ESM chunk under `script-src 'self'`.
|
||||
|
||||
## Reproducing it
|
||||
|
||||
```bash
|
||||
# 1. build the module's client chunk (its CI would do this and ship the result)
|
||||
cd modules/uo/client && npm install && npm run build # → dist/entry.js
|
||||
|
||||
# 2. build core's client
|
||||
cd ../../../client && npm install && npm run build
|
||||
|
||||
# 3. run the server against the local MariaDB
|
||||
cd ../server && npm start
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- `/uo/atlas` and `/uo/atlas/lizardman` render from the module's chunk.
|
||||
- `GET /api/v1/public/atlas/*` answers exactly as before — `npm run routes:manifest -- --check`
|
||||
reports the surface unchanged.
|
||||
- `npm test` in `server/` (core, 729) and `node --test` in `modules/uo/server/` (module, 81).
|
||||
|
||||
`dist/entry.js` is committed here **only** because this branch is the evidence for a design
|
||||
decision and a reviewer should be able to inspect the built artifact without a toolchain. A real
|
||||
module publishes it from CI into its release bundle and never commits it.
|
||||
|
||||
## What in here is not design
|
||||
|
||||
Three things are consequences of stopping at six routes, spelled out in MODULE_API.md §7.5:
|
||||
|
||||
1. **Core reaches into this module twice** — `server/src/router/v1/admin/shardAtlas.controller.js`
|
||||
and `server/test/atlasController.test.js`. The five admin atlas routes sit inside the `/shard`
|
||||
admin prefix core still owns, so they cannot move until the whole prefix does.
|
||||
2. **`server/utils/visibility.js` is a copy** of core's `utils/shardVisibility.js`, which core still
|
||||
needs for the shard routes not yet extracted. Two caches over one table, briefly.
|
||||
3. **There is no `swagger-fragment.json`** — it needs core's merge helper on the other side, which
|
||||
is Phase 2.
|
||||
|
||||
Also note the table names here are `shard_*`, not `uo_*`. That is deliberate and grandfathered by an
|
||||
allowlist in the loader: renaming twenty-seven live tables is a data migration this workstream does
|
||||
not do. Every module written after this one carries its id as a table prefix.
|
||||
409
modules/uo/client/dist/entry.js
vendored
Normal file
409
modules/uo/client/dist/entry.js
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
const B = window.__rg.jsxRuntime, { jsx: t, jsxs: l, Fragment: A } = B, U = window.__rg.react, {
|
||||
useState: h,
|
||||
useEffect: I,
|
||||
useMemo: j,
|
||||
useCallback: W,
|
||||
useRef: ne,
|
||||
useContext: se,
|
||||
useReducer: re,
|
||||
createElement: ie,
|
||||
cloneElement: le,
|
||||
createContext: oe,
|
||||
forwardRef: ce,
|
||||
memo: de,
|
||||
Fragment: me,
|
||||
Children: pe,
|
||||
isValidElement: ue,
|
||||
StrictMode: he,
|
||||
Suspense: ge,
|
||||
lazy: fe
|
||||
} = U, E = window.__rg.router, {
|
||||
Link: z,
|
||||
NavLink: ye,
|
||||
Navigate: xe,
|
||||
Outlet: we,
|
||||
Route: be,
|
||||
Routes: ve,
|
||||
useParams: M,
|
||||
useNavigate: Se,
|
||||
useLocation: Ne,
|
||||
useSearchParams: $e,
|
||||
createBrowserRouter: Ce,
|
||||
RouterProvider: ke
|
||||
} = E, { PublicLayout: F, PageHeader: D, Loading: C, ErrorState: v, EmptyState: S, useAsync: k, useAuth: Re, useSite: ze } = window.__rg.ui, { request: f } = window.__rg.api, w = (e) => e ? `?${e}` : "", O = {
|
||||
creatures: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.q && a.set("q", e.q), e.facet && a.set("facet", e.facet), e.limit && a.set("limit", e.limit), e.offset && a.set("offset", e.offset), f(`/public/atlas/creatures${w(a.toString())}`);
|
||||
},
|
||||
creature: (e, a = {}) => {
|
||||
const s = new URLSearchParams();
|
||||
return a.facet && s.set("facet", a.facet), a.points && s.set("points", a.points), f(`/public/atlas/creatures/${encodeURIComponent(e)}${w(s.toString())}`);
|
||||
},
|
||||
regions: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/regions${w(a.toString())}`);
|
||||
},
|
||||
landmarks: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/landmarks${w(a.toString())}`);
|
||||
},
|
||||
champions: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), f(`/public/atlas/champions${w(a.toString())}`);
|
||||
},
|
||||
meta: () => f("/public/atlas/meta")
|
||||
}, y = { atlas: O }, H = 50, x = (e) => Number.isFinite(e) ? e.toLocaleString() : "—", Q = [
|
||||
{ key: "creatures", label: "Creatures" },
|
||||
{ key: "champions", label: "Champion altars" },
|
||||
{ key: "places", label: "Places" }
|
||||
];
|
||||
function R({ active: e, onClick: a, children: s }) {
|
||||
return /* @__PURE__ */ t(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: a,
|
||||
className: "sans",
|
||||
style: {
|
||||
fontSize: "0.78rem",
|
||||
padding: "5px 12px",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
color: e ? "var(--bg-deep)" : "var(--muted)",
|
||||
background: e ? "var(--accent)" : "transparent",
|
||||
border: `1px solid ${e ? "var(--accent)" : "var(--line)"}`
|
||||
},
|
||||
children: s
|
||||
}
|
||||
);
|
||||
}
|
||||
function G({ creature: e }) {
|
||||
const a = Object.entries(e.facets || {}).sort((s, r) => r[1] - s[1]);
|
||||
return /* @__PURE__ */ l(
|
||||
z,
|
||||
{
|
||||
to: `/uo/atlas/${encodeURIComponent(e.slug)}`,
|
||||
className: "panel",
|
||||
style: {
|
||||
padding: "13px 15px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
textDecoration: "none",
|
||||
color: "inherit"
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
||||
/* @__PURE__ */ t(
|
||||
"div",
|
||||
{
|
||||
className: "display",
|
||||
style: {
|
||||
fontSize: "0.98rem",
|
||||
color: "var(--head)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
children: e.name
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ t("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: a.length === 0 ? "—" : a.map(([s, r]) => `${s} (${r})`).join(" · ") })
|
||||
] }),
|
||||
/* @__PURE__ */ l("div", { className: "sans", style: { flex: "none", textAlign: "right" }, children: [
|
||||
/* @__PURE__ */ t("div", { style: { color: "var(--head)", fontSize: "0.92rem" }, children: x(e.total) }),
|
||||
/* @__PURE__ */ l("div", { className: "dim", style: { fontSize: "0.68rem", letterSpacing: "0.05em" }, children: [
|
||||
x(e.points),
|
||||
" spawners"
|
||||
] })
|
||||
] })
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
function V({ q: e, facet: a }) {
|
||||
const [s, r] = h({ loading: !0, error: null, items: [], total: 0 }), [i, p] = h(!1), o = W(
|
||||
async (n) => await y.atlas.creatures({ q: e, facet: a, limit: H, offset: n }),
|
||||
[e, a]
|
||||
);
|
||||
I(() => {
|
||||
let n = !0;
|
||||
return r({ loading: !0, error: null, items: [], total: 0 }), o(0).then((d) => {
|
||||
n && r({ loading: !1, error: null, items: d.creatures || [], total: d.total || 0 });
|
||||
}).catch((d) => n && r({ loading: !1, error: d, items: [], total: 0 })), () => {
|
||||
n = !1;
|
||||
};
|
||||
}, [o]);
|
||||
const c = async () => {
|
||||
p(!0);
|
||||
try {
|
||||
const n = await o(s.items.length);
|
||||
r((d) => ({ ...d, items: [...d.items, ...n.creatures || []], total: n.total ?? d.total }));
|
||||
} catch {
|
||||
} finally {
|
||||
p(!1);
|
||||
}
|
||||
};
|
||||
return s.loading ? /* @__PURE__ */ t(C, {}) : s.error ? /* @__PURE__ */ t(v, { message: "Could not load the bestiary right now." }) : s.items.length === 0 ? /* @__PURE__ */ t(S, { children: "Nothing in the atlas matches that." }) : /* @__PURE__ */ l(A, { children: [
|
||||
/* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.78rem", margin: "0 0 12px" }, children: [
|
||||
"Showing ",
|
||||
x(s.items.length),
|
||||
" of ",
|
||||
x(s.total)
|
||||
] }),
|
||||
/* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: s.items.map((n) => /* @__PURE__ */ t(G, { creature: n }, n.slug)) }),
|
||||
s.items.length < s.total && /* @__PURE__ */ t("div", { style: { textAlign: "center", marginTop: 16 }, children: /* @__PURE__ */ t("button", { type: "button", className: "btn", onClick: c, disabled: i, children: i ? "Loading…" : "Load more" }) })
|
||||
] });
|
||||
}
|
||||
function X({ facet: e }) {
|
||||
const { loading: a, error: s, data: r } = k(() => y.atlas.champions(e), [e]);
|
||||
return a ? /* @__PURE__ */ t(C, {}) : s ? /* @__PURE__ */ t(v, { message: "Could not load the champion altars right now." }) : !r || r.length === 0 ? /* @__PURE__ */ t(S, { children: "No champion altars are configured." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: r.map((i) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "13px 15px", display: "flex", gap: 14, alignItems: "center" }, children: [
|
||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
||||
/* @__PURE__ */ t("div", { className: "display", style: { fontSize: "0.98rem", color: "var(--head)" }, children: i.label || i.name }),
|
||||
/* @__PURE__ */ l("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: [
|
||||
i.facet,
|
||||
i.group ? ` · ${i.group}` : "",
|
||||
" · ",
|
||||
i.x,
|
||||
", ",
|
||||
i.y
|
||||
] })
|
||||
] }),
|
||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: "none", fontSize: "0.76rem", color: "var(--muted)" }, children: i.randomType ? "Random champion" : i.type || "—" })
|
||||
] }, i.slug)) });
|
||||
}
|
||||
function J({ q: e, facet: a }) {
|
||||
const { loading: s, error: r, data: i } = k(
|
||||
() => Promise.all([y.atlas.regions({ q: e, facet: a }), y.atlas.landmarks({ q: e, facet: a })]),
|
||||
[e, a]
|
||||
), p = j(() => {
|
||||
if (!i) return [];
|
||||
const [o, c] = i;
|
||||
return [
|
||||
...o.map((n) => ({ key: `r:${n.facet}:${n.name}`, name: n.name, facet: n.facet, detail: n.parent || n.type || "Region", kind: "Region" })),
|
||||
...c.map((n) => ({ key: `l:${n.facet}:${n.group || ""}:${n.name}:${n.x}:${n.y}`, name: n.group ? `${n.group} — ${n.name}` : n.name, facet: n.facet, detail: `${n.x}, ${n.y}`, kind: "Landmark" }))
|
||||
].sort((n, d) => n.name.localeCompare(d.name));
|
||||
}, [i]);
|
||||
return s ? /* @__PURE__ */ t(C, {}) : r ? /* @__PURE__ */ t(v, { message: "Could not load places right now." }) : p.length === 0 ? /* @__PURE__ */ t(S, { children: "No regions or landmarks match that." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: p.map((o) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "10px 14px", display: "flex", gap: 12, alignItems: "baseline" }, children: [
|
||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: 1, minWidth: 0, color: "var(--head)", fontSize: "0.88rem" }, children: o.name }),
|
||||
/* @__PURE__ */ l("span", { className: "sans dim", style: { fontSize: "0.72rem" }, children: [
|
||||
o.facet,
|
||||
" · ",
|
||||
o.detail
|
||||
] }),
|
||||
/* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.66rem", letterSpacing: "0.06em", flex: "none" }, children: o.kind })
|
||||
] }, o.key)) });
|
||||
}
|
||||
function K() {
|
||||
var L, _, q;
|
||||
const [e, a] = h("creatures"), [s, r] = h(""), [i, p] = h(""), [o, c] = h(""), n = k(() => y.atlas.meta());
|
||||
I(() => {
|
||||
const m = setTimeout(() => p(s.trim()), 250);
|
||||
return () => clearTimeout(m);
|
||||
}, [s]);
|
||||
const d = ((L = n.data) == null ? void 0 : L.facets) || [], u = ((_ = n.data) == null ? void 0 : _.counts) || null, N = (q = n.data) != null && q.importedAt ? new Date(n.data.importedAt) : null;
|
||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
||||
/* @__PURE__ */ t(
|
||||
D,
|
||||
{
|
||||
eyebrow: "Bestiary",
|
||||
title: "Spawn atlas",
|
||||
lead: "Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
}
|
||||
),
|
||||
u && /* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.76rem", margin: "-12px 0 18px" }, children: [
|
||||
x(u.creatures),
|
||||
" creatures across ",
|
||||
x(u.points),
|
||||
" spawners",
|
||||
Number.isFinite(u.unresolvedPoints) && u.points ? ` · ${Math.round((u.points - u.unresolvedPoints) / u.points * 100)}% placed to a named region or landmark` : "",
|
||||
N ? ` · parsed ${N.toLocaleDateString()}` : ""
|
||||
] }),
|
||||
/* @__PURE__ */ t("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: Q.map((m) => /* @__PURE__ */ t(R, { active: e === m.key, onClick: () => a(m.key), children: m.label }, m.key)) }),
|
||||
e !== "champions" && /* @__PURE__ */ t(
|
||||
"input",
|
||||
{
|
||||
className: "input",
|
||||
type: "search",
|
||||
value: s,
|
||||
onChange: (m) => r(m.target.value),
|
||||
placeholder: e === "creatures" ? "Search creatures…" : "Search regions and landmarks…",
|
||||
style: { width: "100%", marginBottom: 12 }
|
||||
}
|
||||
),
|
||||
d.length > 0 && /* @__PURE__ */ l("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }, children: [
|
||||
/* @__PURE__ */ t(R, { active: o === "", onClick: () => c(""), children: "All facets" }),
|
||||
d.map((m) => /* @__PURE__ */ t(R, { active: o === m, onClick: () => c(m), children: m }, m))
|
||||
] }),
|
||||
n.error && /* @__PURE__ */ t(v, { message: "Could not load the atlas right now." }),
|
||||
!n.error && !n.loading && !N && /* @__PURE__ */ t(S, { children: "The spawn atlas has not been imported yet." }),
|
||||
!n.error && N && /* @__PURE__ */ l(A, { children: [
|
||||
e === "creatures" && /* @__PURE__ */ t(V, { q: i, facet: o }),
|
||||
e === "champions" && /* @__PURE__ */ t(X, { facet: o }),
|
||||
e === "places" && /* @__PURE__ */ t(J, { q: i, facet: o })
|
||||
] })
|
||||
] }) });
|
||||
}
|
||||
const g = (e) => Number.isFinite(e) ? e.toLocaleString() : "—";
|
||||
function Y(e, a) {
|
||||
const s = (r) => r >= 60 ? `${Math.round(r / 60)}m` : `${r}s`;
|
||||
return !Number.isFinite(e) || !Number.isFinite(a) ? null : e === a ? s(e) : `${s(e)}–${s(a)}`;
|
||||
}
|
||||
function P({ title: e, right: a, children: s }) {
|
||||
return /* @__PURE__ */ l("section", { className: "panel", style: { padding: 18 }, children: [
|
||||
/* @__PURE__ */ l("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [
|
||||
/* @__PURE__ */ t("h2", { className: "display", style: { margin: "0 0 12px", fontSize: "1.02rem", color: "var(--head)" }, children: e }),
|
||||
a
|
||||
] }),
|
||||
s
|
||||
] });
|
||||
}
|
||||
function Z({ places: e }) {
|
||||
return e.length === 0 ? /* @__PURE__ */ t("p", { className: "sans dim", style: { margin: 0 }, children: "No placed spawners." }) : /* @__PURE__ */ t("div", { children: e.map((a) => /* @__PURE__ */ l(
|
||||
"div",
|
||||
{
|
||||
className: "sans",
|
||||
style: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "6px 0",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
fontSize: "0.86rem"
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ t("span", { style: { minWidth: 0, color: "var(--head)" }, children: a.label }),
|
||||
/* @__PURE__ */ l("span", { className: "dim", style: { flex: "none" }, children: [
|
||||
a.facet,
|
||||
" · ",
|
||||
g(a.spawners),
|
||||
" spawner",
|
||||
a.spawners === 1 ? "" : "s",
|
||||
" · up to",
|
||||
" ",
|
||||
g(a.maxAlive),
|
||||
" at once"
|
||||
] })
|
||||
]
|
||||
},
|
||||
`${a.facet}:${a.label}`
|
||||
)) });
|
||||
}
|
||||
function ee({ spawners: e, truncated: a }) {
|
||||
const [s, r] = h(!1);
|
||||
return e.length === 0 ? null : /* @__PURE__ */ t(
|
||||
P,
|
||||
{
|
||||
title: "Individual spawners",
|
||||
right: /* @__PURE__ */ t(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "sans",
|
||||
onClick: () => r((i) => !i),
|
||||
style: { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: "0.78rem" },
|
||||
children: s ? "Hide" : `Show ${g(e.length)}`
|
||||
}
|
||||
),
|
||||
children: s && /* @__PURE__ */ l("div", { style: { overflowX: "auto" }, children: [
|
||||
/* @__PURE__ */ l("table", { className: "sans", style: { width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }, children: [
|
||||
/* @__PURE__ */ t("thead", { children: /* @__PURE__ */ l("tr", { style: { textAlign: "left", color: "var(--muted)" }, children: [
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Place" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Facet" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Coords" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Max" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 0 8px 0" }, children: "Respawn" })
|
||||
] }) }),
|
||||
/* @__PURE__ */ t("tbody", { children: e.map((i) => /* @__PURE__ */ l("tr", { style: { borderTop: "1px solid var(--line)" }, children: [
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0", color: "var(--head)" }, children: i.label }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: i.facet }),
|
||||
/* @__PURE__ */ l("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: [
|
||||
i.x,
|
||||
", ",
|
||||
i.y
|
||||
] }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: g(i.maxCount) }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 0" }, className: "dim", children: Y(i.minDelay, i.maxDelay) || "—" })
|
||||
] }, i.id)) })
|
||||
] }),
|
||||
a && /* @__PURE__ */ t("p", { className: "sans dim", style: { fontSize: "0.74rem", margin: "10px 0 0" }, children: "Only the largest spawners are listed." })
|
||||
] })
|
||||
}
|
||||
);
|
||||
}
|
||||
function te() {
|
||||
var o;
|
||||
const { slug: e } = M(), { loading: a, error: s, data: r } = k(() => y.atlas.creature(e), [e]), i = (s == null ? void 0 : s.status) === 404 || (s == null ? void 0 : s.message) === "Not Found", p = j(
|
||||
() => Object.entries((r == null ? void 0 : r.facets) || {}).sort((c, n) => n[1] - c[1]),
|
||||
[r]
|
||||
);
|
||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
||||
/* @__PURE__ */ t("p", { className: "sans", style: { marginBottom: 8 }, children: /* @__PURE__ */ t(z, { to: "/uo/atlas", style: { color: "var(--accent)", fontSize: "0.78rem" }, children: "← Spawn atlas" }) }),
|
||||
a && /* @__PURE__ */ t(C, {}),
|
||||
s && !i && /* @__PURE__ */ t(v, { message: "Could not load that creature right now." }),
|
||||
i && /* @__PURE__ */ t(S, { children: "Nothing by that name spawns on this shard." }),
|
||||
!a && !s && r && /* @__PURE__ */ l(A, { children: [
|
||||
/* @__PURE__ */ t(
|
||||
D,
|
||||
{
|
||||
eyebrow: "Bestiary",
|
||||
title: r.name,
|
||||
lead: `Up to ${g(r.total)} alive at once across ${g(r.points)} spawner${r.points === 1 ? "" : "s"}.`
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ l("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
|
||||
/* @__PURE__ */ t(
|
||||
P,
|
||||
{
|
||||
title: "Where it spawns",
|
||||
right: /* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.74rem" }, children: p.map(([c, n]) => `${c} (${n})`).join(" · ") }),
|
||||
children: /* @__PURE__ */ t(Z, { places: r.places || [] })
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ t(ee, { spawners: r.spawners || [], truncated: !!r.spawnersTruncated }),
|
||||
((o = r.alsoHere) == null ? void 0 : o.length) > 0 && /* @__PURE__ */ t(P, { title: "Shares a spawner with", children: /* @__PURE__ */ t("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: r.alsoHere.map((c) => /* @__PURE__ */ l(
|
||||
z,
|
||||
{
|
||||
to: `/uo/atlas/${encodeURIComponent(c.slug)}`,
|
||||
className: "sans",
|
||||
style: {
|
||||
fontSize: "0.78rem",
|
||||
padding: "4px 11px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--muted)",
|
||||
textDecoration: "none"
|
||||
},
|
||||
children: [
|
||||
c.name,
|
||||
" ",
|
||||
/* @__PURE__ */ l("span", { className: "dim", children: [
|
||||
"×",
|
||||
g(c.shared)
|
||||
] })
|
||||
]
|
||||
},
|
||||
c.slug
|
||||
)) }) })
|
||||
] })
|
||||
] })
|
||||
] }) });
|
||||
}
|
||||
const $ = "uo", T = "^1.0.0";
|
||||
function ae(e) {
|
||||
const [a] = String(e || "").split(".");
|
||||
return a === T.replace(/^\^/, "").split(".")[0];
|
||||
}
|
||||
const b = window.__rg;
|
||||
b ? ae(b.version) ? (b.registry.registerRoutes($, {
|
||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
||||
public: [
|
||||
{ path: "atlas", element: /* @__PURE__ */ t(K, {}) },
|
||||
{ path: "atlas/:slug", element: /* @__PURE__ */ t(te, {}) }
|
||||
]
|
||||
}), b.registry.registerNav($, {
|
||||
area: "public",
|
||||
items: [{ label: "Atlas", to: "/uo/atlas", feature: "atlas", order: 12 }]
|
||||
})) : console.error(`[module-${$}] needs core API ${T}, this core is ${b.version} — not registering`) : console.error(`[module-${$}] window.__rg is missing — core did not publish its shared dependencies`);
|
||||
1691
modules/uo/client/package-lock.json
generated
Normal file
1691
modules/uo/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
modules/uo/client/package.json
Normal file
13
modules/uo/client/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "module-uo-client",
|
||||
"private": true,
|
||||
"version": "0.1.0-spike",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
47
modules/uo/client/src/api.js
Normal file
47
modules/uo/client/src/api.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// module-uo's API bindings.
|
||||
//
|
||||
// These used to be `api.atlas` inside core's client/src/api/client.js — a module
|
||||
// namespace living in core (MODULE_API.md §3.5). The module owns the paths
|
||||
// because it owns the routes at the other end; core hands over only the request
|
||||
// primitive: same-origin /api/v1, cookies included, JSON in/out, ApiError on a
|
||||
// non-2xx.
|
||||
const { request } = window.__rg.api
|
||||
|
||||
const withQs = (s) => (s ? `?${s}` : '')
|
||||
|
||||
export const atlas = {
|
||||
creatures: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return request(`/public/atlas/creatures${withQs(qs.toString())}`)
|
||||
},
|
||||
creature: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.points) qs.set('points', opts.points)
|
||||
return request(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
||||
},
|
||||
regions: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return request(`/public/atlas/regions${withQs(qs.toString())}`)
|
||||
},
|
||||
landmarks: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return request(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||
},
|
||||
champions: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
return request(`/public/atlas/champions${withQs(qs.toString())}`)
|
||||
},
|
||||
meta: () => request('/public/atlas/meta'),
|
||||
}
|
||||
|
||||
export const api = { atlas }
|
||||
53
modules/uo/client/src/entry.jsx
Normal file
53
modules/uo/client/src/entry.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── module-uo · client entry point ─────────────────────────────────────────
|
||||
//
|
||||
// The prebuilt ESM chunk core loads as
|
||||
// `<script type="module" src="/modules/uo/entry.js">`. Same-origin, so
|
||||
// `script-src 'self'` admits it with no nonce and no inline — which is the
|
||||
// entire reason the client half is shaped this way (MODULE_SYSTEM.md §1.14).
|
||||
//
|
||||
// It evaluates AFTER core's bundle (deferred module scripts run in document
|
||||
// order) and BEFORE core renders (main.jsx waits for DOMContentLoaded), so
|
||||
// registering synchronously here is enough — there is no loading state to
|
||||
// coordinate and no re-render to trigger.
|
||||
|
||||
import Atlas from './pages/Atlas.jsx'
|
||||
import AtlasCreature from './pages/AtlasCreature.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
const CORE_API = '^1.0.0'
|
||||
|
||||
// The client-side twin of the server's coreApi check. A module built against a
|
||||
// contract this core does not implement must refuse to register rather than
|
||||
// half-work: a missing kit member is a blank page three clicks in, and the
|
||||
// version is knowable now.
|
||||
function compatible(version) {
|
||||
const [major] = String(version || '').split('.')
|
||||
return major === CORE_API.replace(/^\^/, '').split('.')[0]
|
||||
}
|
||||
|
||||
const rg = window.__rg
|
||||
|
||||
if (!rg) {
|
||||
// Not an exception: throwing from a module script is an uncaught error in the
|
||||
// page, and a module failing to load must never be the site failing to load.
|
||||
console.error(`[module-${ID}] window.__rg is missing — core did not publish its shared dependencies`)
|
||||
} else if (!compatible(rg.version)) {
|
||||
console.error(`[module-${ID}] needs core API ${CORE_API}, this core is ${rg.version} — not registering`)
|
||||
} else {
|
||||
rg.registry.registerRoutes(ID, {
|
||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
||||
public: [
|
||||
{ path: 'atlas', element: <Atlas /> },
|
||||
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
||||
],
|
||||
})
|
||||
|
||||
// Interleaves into core's public nav rather than appending a "UO" group.
|
||||
// `order: 12` puts it where the Atlas link already sat — after Wiki and the
|
||||
// shard boards, before About. `feature` is resolved by the provider below.
|
||||
rg.registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas', order: 12 }],
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -52,7 +49,7 @@ function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
@@ -138,7 +135,7 @@ export default function AtlasCreature() {
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
@@ -175,7 +172,7 @@ export default function AtlasCreature() {
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
|
||||
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const reactDom = window.__rg.reactDom
|
||||
export default reactDom
|
||||
export const { createRoot, hydrateRoot } = reactDom
|
||||
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal file
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// The automatic JSX runtime, from core's global. Every .jsx file in this module
|
||||
// compiles to imports from here, so this is the single hottest path in the
|
||||
// bundle — and the one that would silently produce a SECOND React if it resolved
|
||||
// to a bundled copy instead.
|
||||
const jsx = window.__rg.jsxRuntime
|
||||
export default jsx
|
||||
export const { jsx: jsxFn, jsxs, Fragment } = jsx
|
||||
export { jsxFn as jsx }
|
||||
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal file
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// react-router-dom from core's global. Same singleton argument as React, with a
|
||||
// sharper edge: the router's context is created by whichever copy is loaded, so
|
||||
// a second copy would give module pages an EMPTY router context — <Link> would
|
||||
// throw and useParams() would return {} rather than the URL's params.
|
||||
const router = window.__rg.router
|
||||
export default router
|
||||
export const {
|
||||
Link, NavLink, Navigate, Outlet, Route, Routes, useParams, useNavigate,
|
||||
useLocation, useSearchParams, createBrowserRouter, RouterProvider,
|
||||
} = router
|
||||
19
modules/uo/client/src/shim/react.js
vendored
Normal file
19
modules/uo/client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// React, taken from core's global rather than bundled.
|
||||
//
|
||||
// There is exactly ONE React in the page and core owns it (MODULE_API.md §3.2).
|
||||
// A module that bundled its own would get a second hook dispatcher and fail at
|
||||
// the first useState — so `react` is declared external in vite.config.js and
|
||||
// aliased here.
|
||||
//
|
||||
// Why an alias module rather than rollup's `output.globals`: `globals` only
|
||||
// applies to iife/umd output, and this is an ES module. An alias is the ESM
|
||||
// equivalent, and it also keeps named imports (`import { useState } from
|
||||
// 'react'`) working unchanged in the page source.
|
||||
const react = window.__rg.react
|
||||
|
||||
export default react
|
||||
export const {
|
||||
useState, useEffect, useMemo, useCallback, useRef, useContext, useReducer,
|
||||
createElement, cloneElement, createContext, forwardRef, memo, Fragment,
|
||||
Children, isValidElement, StrictMode, Suspense, lazy,
|
||||
} = react
|
||||
13
modules/uo/client/src/ui.js
Normal file
13
modules/uo/client/src/ui.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Core's shared UI kit, from the global.
|
||||
//
|
||||
// This is the §3.4 kit: a curated, closed set — the layout chrome, the three
|
||||
// page states, the async hook and the two read-only contexts. It exists because
|
||||
// a module page that does not use core's layout is a module page that does not
|
||||
// look like the site it is installed in, and drifts further every time core's
|
||||
// chrome changes.
|
||||
//
|
||||
// Anything NOT in here, the module bundles itself.
|
||||
const { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite } =
|
||||
window.__rg.ui
|
||||
|
||||
export { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite }
|
||||
43
modules/uo/client/vite.config.js
Normal file
43
modules/uo/client/vite.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// Library mode: one prebuilt ESM chunk, published by CI and dropped onto the
|
||||
// operator's volume. The operator never builds anything (MODULE_SYSTEM.md §2.5).
|
||||
//
|
||||
// The four externals are the whole contract with core. Declaring them external
|
||||
// alone is not enough, though: rollup would emit bare `import 'react'`
|
||||
// specifiers, which a browser cannot resolve without an import map — and CSP
|
||||
// forbids the inline <script type="importmap"> that would provide one. So each
|
||||
// is ALIASED to a two-line shim that re-exports from window.__rg, and the
|
||||
// external list then only has to stop Vite from following them into node_modules
|
||||
// this package does not have.
|
||||
const shim = (f) => path.resolve(import.meta.dirname, 'src/shim', f)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// EXACT matches, via the array form. Vite's object form does PREFIX
|
||||
// replacement, so a plain `react` key also rewrote `react/jsx-runtime` into
|
||||
// `src/shim/react.js/jsx-runtime` — a path that does not exist, and the
|
||||
// first thing this build hit.
|
||||
alias: [
|
||||
{ find: /^react$/, replacement: shim('react.js') },
|
||||
{ find: /^react\/jsx-runtime$/, replacement: shim('react-jsx-runtime.js') },
|
||||
{ find: /^react-dom\/client$/, replacement: shim('react-dom-client.js') },
|
||||
{ find: /^react-router-dom$/, replacement: shim('react-router-dom.js') },
|
||||
],
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(import.meta.dirname, 'src/entry.jsx'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'entry.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
// Same reason as core's client: no inline bootstrap script for
|
||||
// `script-src 'self'` to trip on.
|
||||
modulePreload: { polyfill: false },
|
||||
},
|
||||
})
|
||||
14
modules/uo/module.json
Normal file
14
modules/uo/module.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "0.1.0-spike",
|
||||
"coreApi": "^1.0.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
"purge": "server/db/purge.sql",
|
||||
"mounts": {
|
||||
"public": ["/atlas"]
|
||||
},
|
||||
"capabilities": ["atlas"]
|
||||
}
|
||||
88
modules/uo/server/core.js
Normal file
88
modules/uo/server/core.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// ── The module's single point of contact with core ─────────────────────────
|
||||
//
|
||||
// Every other file in this module imports THIS file instead of reaching into
|
||||
// the website's tree. That is the whole mechanical trick behind the
|
||||
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
|
||||
// changed by one `require` line each, and a CI grep for a relative path
|
||||
// escaping the module root can then be an exact test rather than a heuristic.
|
||||
//
|
||||
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
|
||||
// that need it are plain CommonJS modules that were written against top-level
|
||||
// requires. Rather than thread ctx through nine constructors, register() parks
|
||||
// it here once and everything else reads it lazily.
|
||||
//
|
||||
// Lazily is load-bearing: this file is required at module-require time, which is
|
||||
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
|
||||
// startup-time database dependency the loader is careful not to have.
|
||||
|
||||
let ctx = null
|
||||
|
||||
/** Called exactly once, by server/index.js, at the top of register(). */
|
||||
function init(next) {
|
||||
if (ctx) throw new Error('module-uo: core.init() called twice')
|
||||
ctx = next
|
||||
}
|
||||
|
||||
function require_() {
|
||||
if (!ctx) throw new Error('module-uo: core used before register() ran')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Forwarders rather than re-exports: `const { query } = require('./core')`
|
||||
// destructures at require time, which is before init(), so a plain re-export
|
||||
// would capture undefined. Each of these resolves ctx at CALL time.
|
||||
const query = (sql, params) => require_().db.query(sql, params)
|
||||
|
||||
const logger = (namespace) => require_().log(namespace)
|
||||
|
||||
const settings = {
|
||||
get: (key) => require_().settings.get(key),
|
||||
// `updatedBy` is the third parameter core's settings.model.set carries — the
|
||||
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
|
||||
// would silently lose the audit attribution rather than fail.
|
||||
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
|
||||
getInstanceName: () => require_().settings.getInstanceName(),
|
||||
}
|
||||
|
||||
const auth = {
|
||||
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
|
||||
}
|
||||
|
||||
const middleware = {
|
||||
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
|
||||
validate: (req, res, next) => require_().middleware.validate(req, res, next),
|
||||
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
|
||||
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
|
||||
requireRole: (...roles) => {
|
||||
// requireRole is a FACTORY, so it must be resolved at call time and the
|
||||
// resulting middleware kept — resolving it per request would build a new
|
||||
// closure on every hit.
|
||||
let built = null
|
||||
return (req, res, next) => {
|
||||
built = built || require_().middleware.requireRole(...roles)
|
||||
return built(req, res, next)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
// Shared server dependencies, taken from core rather than required directly.
|
||||
// A module lives outside server/, so `require('express')` from here does not
|
||||
// resolve at all — and even where it did, a second express in the process
|
||||
// would be a second Router prototype. Same rule as React on the client.
|
||||
get express() { return require_().express },
|
||||
get validator() { return require_().validator },
|
||||
query,
|
||||
logger,
|
||||
settings,
|
||||
auth,
|
||||
middleware,
|
||||
get pool() { return require_().db.pool },
|
||||
get secretBox() { return require_().secretBox },
|
||||
get push() { return require_().push },
|
||||
get uploads() { return require_().uploads },
|
||||
get posts() { return require_().posts },
|
||||
get paths() { return require_().paths },
|
||||
get moduleId() { return require_().moduleId },
|
||||
}
|
||||
21
modules/uo/server/db/purge.sql
Normal file
21
modules/uo/server/db/purge.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- ── module-uo · purge ──────────────────────────────────────────────────────
|
||||
--
|
||||
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
|
||||
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
|
||||
-- retains its data, and an operator who wants the data gone has to say so.
|
||||
--
|
||||
-- Required because this module declares a schema fragment: a module that can
|
||||
-- create tables and cannot drop them leaves an operator with orphaned data and
|
||||
-- no supported way to remove it.
|
||||
--
|
||||
-- Dropped children-first even though these tables carry no foreign keys, so the
|
||||
-- order stays correct if Phase 3 adds one.
|
||||
|
||||
DROP TABLE IF EXISTS shard_atlas_pending;
|
||||
DROP TABLE IF EXISTS shard_atlas_meta;
|
||||
DROP TABLE IF EXISTS shard_champion_spawns;
|
||||
DROP TABLE IF EXISTS shard_landmarks;
|
||||
DROP TABLE IF EXISTS shard_regions;
|
||||
DROP TABLE IF EXISTS shard_spawn_point_types;
|
||||
DROP TABLE IF EXISTS shard_spawn_points;
|
||||
DROP TABLE IF EXISTS shard_spawn_creatures;
|
||||
166
modules/uo/server/db/schema.sql
Normal file
166
modules/uo/server/db/schema.sql
Normal file
@@ -0,0 +1,166 @@
|
||||
-- ── module-uo · schema fragment ────────────────────────────────────────────
|
||||
--
|
||||
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
|
||||
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
|
||||
-- It inherits core's rules because it goes through core's splitter: idempotent
|
||||
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
|
||||
--
|
||||
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
|
||||
-- server/db/schema.sql. Phase 3 brings the other nineteen.
|
||||
--
|
||||
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
|
||||
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
|
||||
-- loader: renaming twenty-seven live tables is a data migration this workstream
|
||||
-- deliberately does not do, and the prefix rule holds for every module written
|
||||
-- after this one.
|
||||
|
||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||
-- comes from the sidecar — it is imported from a committed artifact built off a
|
||||
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
||||
-- these tables stay populated whether the shard is up or not.
|
||||
--
|
||||
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
||||
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
||||
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
||||
|
||||
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
||||
-- each type's own MX across every point that spawns it (how many exist at once);
|
||||
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
||||
-- live" both answer without touching shard_spawn_points.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
||||
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
||||
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
||||
total INT NOT NULL DEFAULT 0,
|
||||
points INT NOT NULL DEFAULT 0,
|
||||
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
||||
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
||||
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
||||
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
||||
-- art when this is NULL, which is the normal case.
|
||||
art VARCHAR(255) NULL,
|
||||
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
||||
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
||||
INDEX idx_shard_spawn_creatures_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
||||
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
||||
-- the resolved display string (region, else landmark, else 'Wilderness').
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
height INT NOT NULL DEFAULT 0,
|
||||
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
||||
max_count INT NOT NULL DEFAULT 0,
|
||||
min_delay INT NOT NULL DEFAULT 0,
|
||||
max_delay INT NOT NULL DEFAULT 0,
|
||||
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
||||
tod_end INT NOT NULL DEFAULT 0,
|
||||
tod_mode INT NOT NULL DEFAULT 0,
|
||||
region VARCHAR(120) NULL,
|
||||
landmark VARCHAR(120) NULL,
|
||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||
INDEX idx_shard_spawn_points_facet (facet),
|
||||
INDEX idx_shard_spawn_points_label (label)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The many-to-many between the two above: one spawner commonly carries several
|
||||
-- types (a single Trammel point spawns six), each with its own max. This is how
|
||||
-- /atlas/creatures/:slug finds the places a creature appears.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
||||
point_id INT NOT NULL,
|
||||
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
||||
max_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (point_id, slug),
|
||||
INDEX idx_shard_spawn_point_types_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
||||
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
||||
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
||||
CREATE TABLE IF NOT EXISTS shard_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
type VARCHAR(80) NULL, -- ServUO region class
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
||||
rects JSON NULL,
|
||||
INDEX idx_shard_regions_facet (facet),
|
||||
INDEX idx_shard_regions_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
||||
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
||||
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
||||
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(120) NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
INDEX idx_shard_landmarks_facet (facet),
|
||||
INDEX idx_shard_landmarks_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
||||
type VARCHAR(80) NULL, -- '' when randomised per activation
|
||||
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
radius INT NOT NULL DEFAULT 0,
|
||||
label VARCHAR(120) NULL, -- resolved place name
|
||||
INDEX idx_shard_champion_spawns_facet (facet)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||
-- is behind the committed artifact.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||
-- NOT applied, because it would remove a facet the site currently serves.
|
||||
--
|
||||
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||
-- blocked by it: the site comes up serving the atlas it already had.
|
||||
--
|
||||
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||
--
|
||||
-- `rejected` is remembered against those exact source hashes so a declined
|
||||
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||
-- hashes and asks again.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||
payload JSON NOT NULL, -- source hashes + facet diff
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
59
modules/uo/server/index.js
Normal file
59
modules/uo/server/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ── module-uo · server entry point ─────────────────────────────────────────
|
||||
//
|
||||
// SPIKE SCOPE. Phase 1 carries only /api/v1/public/atlas/* out of core
|
||||
// (MODULE_SYSTEM.md §2.7): six routes, DB-backed, no sidecar, no SSE, one boot
|
||||
// hook. Phase 3 brings the rest — the other 12 router/controller files, the 7
|
||||
// remaining model directories, the notification-stream catalog and the
|
||||
// town-crier announce leg.
|
||||
//
|
||||
// Called ONCE, synchronously, during the website's app.js require. Everything
|
||||
// here must therefore be synchronous and must not touch the database: the route
|
||||
// manifest generator and the OpenAPI generator both require app.js with the
|
||||
// pool pointed at a dead port, and a module that queried here would hang both
|
||||
// (MODULE_API.md §2.2). Anything needing a live database goes in onBoot.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
module.exports = function register(ctx, api) {
|
||||
// Park ctx before requiring anything that reads it. The requires below pull in
|
||||
// the model layer, whose files resolve core lazily — but the ORDER still
|
||||
// matters for the router, which is constructed at require time.
|
||||
core.init(ctx)
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const atlasRouter = require('./router/atlas.router')
|
||||
const atlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = ctx.log('boot')
|
||||
|
||||
// The URL is unchanged from when this router lived in core's
|
||||
// router/v1/public/index.js — that is the point, and routes.manifest.json is
|
||||
// the proof (MODULE_API.md §5.3).
|
||||
api.registerRoutes({
|
||||
public: { '/atlas': atlasRouter },
|
||||
})
|
||||
|
||||
// Was server.js:92, an explicit call in core's start(). Re-derive the spawn
|
||||
// atlas from the shard's own ServUO tree: the shard's maps change over its
|
||||
// lifetime — facets get added, replaced or renamed — so the atlas is rebuilt
|
||||
// on every boot rather than shipped as a snapshot that would silently go
|
||||
// stale. Hash-gated, so an unchanged tree costs one read pass and no write.
|
||||
//
|
||||
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||
// malformed file must never stop the site coming up, and a refresh that would
|
||||
// REMOVE a facet is staged for admin approval instead of being applied. So it
|
||||
// is caught HERE rather than left to the loader — the loader's catch would be
|
||||
// correct about the failure but wrong about the severity, marking the module
|
||||
// startup_failed and 503-ing six routes that serve perfectly good stale data.
|
||||
api.onBoot(async () => {
|
||||
try {
|
||||
const result = await atlas.refreshOnBoot()
|
||||
log.info('spawn atlas refreshed', { status: result && result.status })
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas refresh failed — serving whatever was last imported', {
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
const { pool, query } = require('../../core')
|
||||
|
||||
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||
// empties and refills all six inside one transaction, and nothing else in the
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAtlas.db')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { settings } = require('../../core')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../utils/logger')('shardAtlas')
|
||||
const log = require('../../core').logger('atlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
//
|
||||
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
const COLS = 'account, user_id, char_name, linked_at'
|
||||
|
||||
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||
// user (the sidecar already treats /link/confirm as authoritative).
|
||||
async function upsert({ account, userId, charName }) {
|
||||
await query(
|
||||
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||
[account, userId, charName || null],
|
||||
)
|
||||
return getByAccount(account)
|
||||
}
|
||||
|
||||
async function getByAccount(account) {
|
||||
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||
|
||||
async function isOwnedBy(account, userId) {
|
||||
const rows = await query(
|
||||
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||
[account, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||
// authoritative link (it tags the game account on /link/confirm); this model
|
||||
// records it locally so the player portal can list links and enforce ownership.
|
||||
|
||||
const db = require('./shardLinks.db')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
account: row.account,
|
||||
userId: row.user_id,
|
||||
charName: row.char_name || null,
|
||||
linkedAt: row.linked_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function link({ account, userId, charName }) {
|
||||
return toSafe(await db.upsert({ account, userId, charName }))
|
||||
}
|
||||
|
||||
async function listForUser(userId) {
|
||||
const rows = await db.listByUser(userId)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||
|
||||
async function getByAccount(account) {
|
||||
return toSafe(await db.getByAccount(account))
|
||||
}
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
|
||||
// compiles a default for every known feature and merges stored rows over it, so
|
||||
// a fresh install with an empty table behaves exactly as the site did pre-v3.
|
||||
|
||||
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
|
||||
|
||||
const getOne = (feature) =>
|
||||
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
|
||||
|
||||
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
|
||||
// {field: rung}; the caller has already stripped locked fields and validated
|
||||
// every rung against the ladder.
|
||||
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
|
||||
query(
|
||||
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
audience = VALUES(audience),
|
||||
stream = VALUES(stream),
|
||||
field_rules = VALUES(field_rules),
|
||||
updated_by = VALUES(updated_by)`,
|
||||
[
|
||||
feature,
|
||||
enabled ? 1 : 0,
|
||||
audience,
|
||||
stream ? 1 : 0,
|
||||
fieldRules == null ? null : JSON.stringify(fieldRules),
|
||||
updatedBy ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
@@ -0,0 +1,44 @@
|
||||
// ── Shard feature visibility (model) ───────────────────────────────────────
|
||||
//
|
||||
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
|
||||
// feature catalog, the locked fields, the kind→feature map — lives in
|
||||
// utils/shardVisibility.js; this file only reads and writes rows.
|
||||
|
||||
const db = require('./shardVisibility.db')
|
||||
|
||||
// The `field_rules` JSON column comes back as a string on the mariadb driver.
|
||||
function parseRules(raw) {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'object') return raw
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const toSafe = (row) =>
|
||||
row && {
|
||||
feature: row.feature,
|
||||
enabled: !!row.enabled,
|
||||
audience: row.audience,
|
||||
stream: row.stream == null ? null : !!row.stream,
|
||||
fieldRules: parseRules(row.field_rules),
|
||||
updatedBy: row.updated_by,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
|
||||
async function listAll() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
async function getOne(feature) {
|
||||
const rows = await db.getOne(feature)
|
||||
return toSafe(rows[0])
|
||||
}
|
||||
|
||||
const upsert = (entry) => db.upsert(entry)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
@@ -21,10 +21,10 @@
|
||||
// not projecting is a bug, and the cost of honouring it is one call per handler
|
||||
// rather than a retrofit the first time a field needs gating.
|
||||
|
||||
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
|
||||
const visibility = require('../../../utils/shardVisibility')
|
||||
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const visibility = require('../utils/visibility')
|
||||
|
||||
const log = require('../../../utils/logger')('public-atlas')
|
||||
const log = require('../core').logger('public-atlas')
|
||||
|
||||
const FEATURE = 'atlas'
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
// The default audience is `anonymous`, so these gates are inert until an admin
|
||||
// changes something.
|
||||
|
||||
const express = require('express')
|
||||
const { param, query } = require('express-validator')
|
||||
|
||||
// express and express-validator come from core, never from a require here: this
|
||||
// file lives outside server/, so Node's resolver would not find them, and a
|
||||
// second express in the process would be a second Router prototype
|
||||
// (docs/website/MODULE_API.md §2.3).
|
||||
const core = require('../core')
|
||||
const atlas = require('./atlas.controller')
|
||||
const siteMode = require('../../../middleware/siteMode')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { requireFeature } = require('../../../utils/shardVisibility')
|
||||
const { requireFeature } = require('../utils/visibility')
|
||||
|
||||
const { express, validator, middleware } = core
|
||||
const { param, query } = validator
|
||||
const { siteMode, validate } = middleware
|
||||
|
||||
const atlasRouter = express.Router()
|
||||
|
||||
60
modules/uo/server/test/_ctx.js
Normal file
60
modules/uo/server/test/_ctx.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// ── Test harness: a fake ctx ───────────────────────────────────────────────
|
||||
//
|
||||
// A module's tests cannot require core — that is the whole zero-internal-imports
|
||||
// rule (docs/website/MODULE_API.md §5.1), and it applies to test files too. So
|
||||
// instead of stubbing core's modules the way core's own tests do, a module test
|
||||
// hands `core.init()` a ctx it fabricated.
|
||||
//
|
||||
// That turns out to be the nicer story: the seam that exists so a module can be
|
||||
// swapped onto a different core is the same seam that lets its tests run with no
|
||||
// database, no express app and no settings table. Core's tests reach the same
|
||||
// place by pointing the mariadb pool at a dead port; a module does not have to.
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
/**
|
||||
* Build and install a fake ctx. Every member is a stub the test can reassign.
|
||||
* @param {object} [over] members to override, deep-merged one level
|
||||
*/
|
||||
function installFakeCtx(over = {}) {
|
||||
const settings = new Map()
|
||||
|
||||
const ctx = {
|
||||
moduleId: 'uo',
|
||||
paths: { moduleRoot: require('path').join(__dirname, '..', '..') },
|
||||
// Null, not the real packages: a module cannot resolve express from outside
|
||||
// server/ (that is why ctx carries them at all), and these tests construct no
|
||||
// router. A test that needs one passes the real ones in `over`.
|
||||
express: null,
|
||||
validator: null,
|
||||
db: {
|
||||
// Every test that needs a query result reassigns this.
|
||||
query: async () => [],
|
||||
pool: { getConnection: async () => { throw new Error('no pool in tests') } },
|
||||
},
|
||||
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
settings: {
|
||||
get: async (key) => (settings.has(key) ? settings.get(key) : null),
|
||||
set: async (key, value) => { settings.set(key, value) },
|
||||
getInstanceName: async () => 'Test Shard',
|
||||
},
|
||||
auth: { getUserFromRequest: () => null },
|
||||
push: { publish: async () => {} },
|
||||
secretBox: { encrypt: (s) => s, decrypt: (s) => s },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
},
|
||||
uploads: {},
|
||||
posts: {},
|
||||
...over,
|
||||
}
|
||||
|
||||
core.init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
module.exports = { installFakeCtx }
|
||||
@@ -15,7 +15,7 @@ const {
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
} = require('../src/utils/spawnAtlasParse')
|
||||
} = require('../utils/spawnAtlasParse')
|
||||
|
||||
// These parsers are pure and fs-free precisely so this suite can run in CI,
|
||||
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
|
||||
@@ -1,6 +1,5 @@
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
// No dead-port pool trick here: a module test fabricates its ctx instead, so
|
||||
// there is no database to point anywhere (see test/_ctx.js).
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
@@ -8,6 +7,10 @@ const path = require('path')
|
||||
const { test, after, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { installFakeCtx } = require('./_ctx')
|
||||
|
||||
installFakeCtx()
|
||||
|
||||
const {
|
||||
AtlasSourceError,
|
||||
aggregateCreatures,
|
||||
@@ -16,13 +19,13 @@ const {
|
||||
hashSources,
|
||||
buildAtlas,
|
||||
PARSER_VERSION,
|
||||
} = require('../src/utils/spawnAtlasSource')
|
||||
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||
const atlasDb = require('../src/model/shardAtlas/shardAtlas.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
} = require('../utils/spawnAtlasSource')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||||
// The model captured this object at require time, so reassigning a method on it
|
||||
// is how a test stubs core — the module equivalent of core's own tests
|
||||
// monkey-patching a model.
|
||||
const { settings } = require('../core')
|
||||
|
||||
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||||
//
|
||||
435
modules/uo/server/utils/visibility.js
Normal file
435
modules/uo/server/utils/visibility.js
Normal file
@@ -0,0 +1,435 @@
|
||||
// ── Shard feature visibility ───────────────────────────────────────────────
|
||||
//
|
||||
// Admin-configurable, per-feature and per-field audience control over every
|
||||
// shard-derived surface on the site. Replaces the hardcoded split that used to
|
||||
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
|
||||
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
|
||||
//
|
||||
// Design rules (docs/link/v3.md §3):
|
||||
//
|
||||
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
|
||||
// is a dumb forwarder: it accepts frames, stores them, forwards them
|
||||
// verbatim, and serves store-backed reads. It defines no audiences.
|
||||
// • Every default reproduces the behavior that shipped before this module, so
|
||||
// installing it changes nothing until an admin edits the config.
|
||||
// • Two rules an admin CANNOT override:
|
||||
// 1. `acct` / `webId` are admin-only, always. They are not in-game
|
||||
// visible (unlike a character name) and are not configurable fields.
|
||||
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
|
||||
// Fail closed — this is what keeps the kind map a security boundary
|
||||
// rather than a convenience filter.
|
||||
//
|
||||
// The audience ladder is ordered; each rung implies the ones below it.
|
||||
|
||||
const db = require('../model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const { auth } = require('../core')
|
||||
const log = require('../core').logger('visibility')
|
||||
|
||||
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
|
||||
const RANK = new Map(LADDER.map((level, i) => [level, i]))
|
||||
|
||||
const isLevel = (level) => RANK.has(level)
|
||||
|
||||
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
|
||||
// point: an unrecognised value must always lose. A single shared fallback cannot
|
||||
// do that — whichever direction it picks, it fails open on one side. So:
|
||||
//
|
||||
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
|
||||
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
|
||||
//
|
||||
// With one `rank()` defaulting to admin, a viewer level that fell through (a
|
||||
// typo, a future rung this build doesn't know, a value from a caller that
|
||||
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
|
||||
const viewerRank = (level) => RANK.get(level) ?? 0
|
||||
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
|
||||
|
||||
// True when a viewer at `viewer` satisfies a requirement of `required`.
|
||||
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||
|
||||
// Exported for tests/diagnostics; `meets` is what callers should use.
|
||||
const rank = viewerRank
|
||||
|
||||
// ── Features ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
|
||||
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
|
||||
// not listed here is visible whenever the feature itself is.
|
||||
//
|
||||
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
|
||||
|
||||
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
||||
|
||||
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
|
||||
// frames nest actors (`leader.acct`), but several read models flatten them
|
||||
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
|
||||
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
|
||||
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
|
||||
// anonymous callers while the same account name was correctly stripped from the
|
||||
// live `house.decay` frame.
|
||||
//
|
||||
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
|
||||
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
|
||||
// fail closed for shapes nobody has written yet.
|
||||
const LOCKED_SUFFIXES = ['acct', 'webid']
|
||||
const isLockedField = (key) => {
|
||||
const k = String(key).toLowerCase()
|
||||
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
|
||||
}
|
||||
|
||||
const FEATURES = {
|
||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||
status: { audience: 'anonymous', fields: {} },
|
||||
activity: { audience: 'anonymous', fields: {} },
|
||||
champs: { audience: 'anonymous', fields: {} },
|
||||
guilds: { audience: 'anonymous', fields: {} },
|
||||
governors: { audience: 'anonymous', fields: {} },
|
||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||
// `owner` is the actor object on the house.decay/house.update frames;
|
||||
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||
// REST read models. Both are listed so one rule covers the wire and the read
|
||||
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||
houses: {
|
||||
audience: 'anonymous',
|
||||
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||
},
|
||||
// /public/shard/online listed linked staff to everyone but gated location to
|
||||
// admin+moderator — which is exactly the `staff` rung.
|
||||
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||
|
||||
// ── New in v3. ──
|
||||
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||
atlas: { audience: 'anonymous', fields: {} },
|
||||
// `name` is the ranked character's name inside points.board's `top` entries, and
|
||||
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
|
||||
// ("characterName"). projectValue matches on the literal JSON key, so a rule
|
||||
// named for the field's meaning rather than its key silently does nothing — the
|
||||
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
|
||||
// leaderboards payload `name` can only be a character name: the board's own
|
||||
// display name arrives as `nameString`/`nameNumber`.
|
||||
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
|
||||
// Shop name, owner character name and vendor location are already globally
|
||||
// visible in-game via the stock Vendor Search gump, so publishing them is not
|
||||
// a new disclosure — but they stay configurable so an admin can tighten them.
|
||||
//
|
||||
// `ownerName` and `location` were pre-wired here by Part A, before the frame
|
||||
// existed; both were re-checked against the real `vendor.listing` and both are
|
||||
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
|
||||
// `location` is a NESTED object on the wire and on the read model precisely so
|
||||
// that one rule hides map, coordinates, region and house together — five flat
|
||||
// keys would be five rules that drift apart.
|
||||
//
|
||||
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||
// lists both: an admin who hides the owner's name and is left with a serial
|
||||
// that every other board resolves back to that name has not hidden anything.
|
||||
market: {
|
||||
audience: 'anonymous',
|
||||
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||
},
|
||||
}
|
||||
|
||||
const FEATURE_NAMES = Object.keys(FEATURES)
|
||||
const isFeature = (name) => Object.hasOwn(FEATURES, name)
|
||||
|
||||
// ── Kind → feature ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every event kind that may ever leave the admin channel must appear here.
|
||||
// Anything else is admin-only by omission (rule 2). This map is seeded from
|
||||
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
|
||||
// same kinds it did — now attributed to a feature that an admin can re-gate.
|
||||
|
||||
const KIND_FEATURE = new Map(
|
||||
Object.entries({
|
||||
// status / lifecycle
|
||||
'server.hello': 'status',
|
||||
'server.shutdown': 'status',
|
||||
'server.crashed': 'status',
|
||||
'economy.supply': 'status',
|
||||
// activity feed
|
||||
'player.death': 'activity',
|
||||
'player.murdered': 'activity',
|
||||
'mob.killed': 'activity',
|
||||
'quest.complete': 'activity',
|
||||
'skill.gain': 'activity',
|
||||
'fame.change': 'activity',
|
||||
'karma.change': 'activity',
|
||||
'mob.login': 'activity',
|
||||
'mob.logout': 'activity',
|
||||
// boards
|
||||
'champ.update': 'champs',
|
||||
'champ.remove': 'champs',
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
'city.update': 'governors',
|
||||
'presence.online': 'presence',
|
||||
'region.enter': 'presence',
|
||||
// house.decay is the IDOC signal the public Houses page renders. The full
|
||||
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||
// off the map deliberately, so it remains admin-only exactly as before.
|
||||
'house.decay': 'houses',
|
||||
// v3
|
||||
'world.ruleset': 'ruleset',
|
||||
'points.board': 'leaderboards',
|
||||
// vendor.listing IS mapped, but the market feature ships with its stream
|
||||
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
|
||||
// inventories would be the site's biggest bandwidth consumer and no page
|
||||
// needs it live. An admin can turn it on.
|
||||
'vendor.listing': 'market',
|
||||
'vendor.listing.remove': 'market',
|
||||
}),
|
||||
)
|
||||
|
||||
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
|
||||
// are unaffected; only the live stream is suppressed.
|
||||
const DEFAULT_STREAM_OFF = new Set(['market'])
|
||||
|
||||
// Back-compat: the set of kinds that reach an anonymous viewer under the default
|
||||
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
|
||||
// this. Derived from the map above rather than hand-maintained, so the two can
|
||||
// no longer drift.
|
||||
const PUBLIC_KINDS = new Set(
|
||||
[...KIND_FEATURE.entries()]
|
||||
.filter(([, feature]) => {
|
||||
if (DEFAULT_STREAM_OFF.has(feature)) return false
|
||||
return FEATURES[feature].audience === 'anonymous'
|
||||
})
|
||||
.map(([kind]) => kind),
|
||||
)
|
||||
|
||||
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
|
||||
|
||||
const CONFIG_TTL_MS = 5000
|
||||
let cache = null
|
||||
let cachedAt = 0
|
||||
|
||||
// Merge a stored row over its compiled default. Unknown feature names in the DB
|
||||
// are ignored (a stale row from a removed feature must not resurrect it), and an
|
||||
// invalid rung falls back to the default rather than failing open.
|
||||
function applyRow(name, row) {
|
||||
const base = FEATURES[name]
|
||||
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||
const fields = { ...base.fields }
|
||||
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
||||
if (isLockedField(field)) continue // rule 1: not configurable
|
||||
if (isLevel(level)) fields[field] = level
|
||||
}
|
||||
return {
|
||||
enabled: row ? !!row.enabled : true,
|
||||
audience,
|
||||
fields,
|
||||
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
|
||||
}
|
||||
}
|
||||
|
||||
function compileDefaults() {
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
|
||||
return out
|
||||
}
|
||||
|
||||
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
|
||||
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
|
||||
// "what the site did before" rather than to "everything is public".
|
||||
async function getConfig() {
|
||||
const now = Date.now()
|
||||
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
|
||||
try {
|
||||
const rows = await db.listAll()
|
||||
const byName = new Map(rows.map((r) => [r.feature, r]))
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
|
||||
cache = out
|
||||
cachedAt = now
|
||||
} catch (err) {
|
||||
log.error('getConfig; falling back to defaults', err)
|
||||
cache = cache || compileDefaults()
|
||||
cachedAt = now
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
cache = null
|
||||
cachedAt = 0
|
||||
}
|
||||
|
||||
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// anonymous no session
|
||||
// logged_in authenticated, no linked game account
|
||||
// player authenticated with a linked game account
|
||||
// staff admin | moderator — the same set as the existing `modAccess` gate.
|
||||
// `editor` is a CONTENT role with no shard privilege today, so it
|
||||
// resolves by link status like any other member; mapping it to staff
|
||||
// here would silently widen what editors can see.
|
||||
// admin admin
|
||||
//
|
||||
// Staff always satisfy the `player` rung (rank order guarantees it) even without
|
||||
// a linked account, matching the existing rule that /player/* is role-agnostic
|
||||
// self-service.
|
||||
|
||||
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
|
||||
// (or newly relinked) account must not keep the old answer for long. Anonymous,
|
||||
// staff and admin callers short-circuit before this runs, so the lookup only
|
||||
// costs a query on the logged-in-member path.
|
||||
const LINK_TTL_MS = CONFIG_TTL_MS
|
||||
const linkCache = new Map() // userId → { hasLink, at }
|
||||
|
||||
async function hasLinkedAccount(userId) {
|
||||
const hit = linkCache.get(userId)
|
||||
const now = Date.now()
|
||||
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
|
||||
let hasLink = false
|
||||
try {
|
||||
const links = await shardLinks.listForUser(userId)
|
||||
hasLink = Array.isArray(links) && links.length > 0
|
||||
} catch (err) {
|
||||
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
|
||||
}
|
||||
linkCache.set(userId, { hasLink, at: now })
|
||||
return hasLink
|
||||
}
|
||||
|
||||
// Drop a user's cached link status (called when a link is created or removed so
|
||||
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
|
||||
const forgetUser = (userId) => linkCache.delete(userId)
|
||||
|
||||
async function viewerLevel(req) {
|
||||
const viewer = req.user || auth.getUserFromRequest(req)
|
||||
if (!viewer) return 'anonymous'
|
||||
if (viewer.role === 'admin') return 'admin'
|
||||
if (viewer.role === 'moderator') return 'staff'
|
||||
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
|
||||
}
|
||||
|
||||
// ── Enforcement ────────────────────────────────────────────────────────────
|
||||
|
||||
// Route gate. 404 when the feature is disabled (do not leak that it exists);
|
||||
// 403 when it exists but the viewer sits below its audience. Stashes the
|
||||
// resolved level on the request so controllers can project without re-resolving.
|
||||
function requireFeature(name) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
const feature = config[name]
|
||||
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
|
||||
const level = await viewerLevel(req)
|
||||
req.viewerLevel = level
|
||||
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error(`requireFeature(${name})`, err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the fields a viewer at `level` may not see. Applies the locked rules
|
||||
// first (so acct/webId can never survive below admin), then the feature's
|
||||
// configured field rules. Recurses into arrays and nested objects because the
|
||||
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
||||
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
|
||||
// instance is a value, not a bag of fields: rebuilding one key-by-key would
|
||||
// return `{}` (a Date has no enumerable own properties), which is how the DB-
|
||||
// backed read models — whose rows carry real Date columns — differ from the
|
||||
// pure-JSON wire frames the projection was first written against.
|
||||
const isPlainObject = (v) => {
|
||||
if (v === null || typeof v !== 'object') return false
|
||||
const proto = Object.getPrototypeOf(v)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
function projectValue(value, rules, level) {
|
||||
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
||||
if (!isPlainObject(value)) return value
|
||||
const out = {}
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
// Locked fields are checked by meaning first, so no configured rule (and no
|
||||
// flattened spelling) can widen them past `admin`.
|
||||
const required = isLockedField(key) ? 'admin' : rules[key]
|
||||
if (required && !meets(level, required)) continue
|
||||
out[key] = projectValue(v, rules, level)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Project a payload for one feature. `level` defaults to admin-equivalent only
|
||||
// when explicitly passed; callers should always pass a resolved level.
|
||||
function projectFeature(name, payload, level, config) {
|
||||
const feature = config?.[name]
|
||||
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
|
||||
return projectValue(payload, rules, level)
|
||||
}
|
||||
|
||||
// Convenience for controllers: resolve config once, project, return.
|
||||
async function project(name, payload, req) {
|
||||
const config = await getConfig()
|
||||
const level = req.viewerLevel || (await viewerLevel(req))
|
||||
return projectFeature(name, payload, level, config)
|
||||
}
|
||||
|
||||
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
|
||||
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
|
||||
function kindVisibleTo(kind, level, config) {
|
||||
if (level === 'admin') return true
|
||||
const name = KIND_FEATURE.get(kind)
|
||||
if (!name) return false // rule 2: unmapped ⇒ admin-only
|
||||
const feature = config?.[name]
|
||||
if (!feature || !feature.enabled || !feature.stream) return false
|
||||
return meets(level, feature.audience)
|
||||
}
|
||||
|
||||
// The event kinds a viewer at `level` may read under the CURRENT config. This is
|
||||
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
|
||||
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
|
||||
// this kind, given what the admin has configured?".
|
||||
//
|
||||
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
|
||||
// feature whose live firehose is off (market) is still readable from the stored
|
||||
// history. Unmapped kinds are absent by construction (rule 2).
|
||||
function visibleKinds(level, config) {
|
||||
return [...KIND_FEATURE.entries()]
|
||||
.filter(([, name]) => {
|
||||
const feature = config?.[name]
|
||||
return !!feature && feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
.map(([kind]) => kind)
|
||||
}
|
||||
|
||||
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||
// renders a link that would 403.
|
||||
function visibleFeatures(level, config) {
|
||||
return FEATURE_NAMES.filter((name) => {
|
||||
const feature = config[name]
|
||||
return feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LADDER,
|
||||
FEATURES,
|
||||
FEATURE_NAMES,
|
||||
LOCKED_FIELDS,
|
||||
KIND_FEATURE,
|
||||
PUBLIC_KINDS,
|
||||
DEFAULT_STREAM_OFF,
|
||||
isLevel,
|
||||
isFeature,
|
||||
isLockedField,
|
||||
rank,
|
||||
meets,
|
||||
getConfig,
|
||||
invalidate,
|
||||
compileDefaults,
|
||||
viewerLevel,
|
||||
forgetUser,
|
||||
requireFeature,
|
||||
projectFeature,
|
||||
project,
|
||||
kindVisibleTo,
|
||||
visibleKinds,
|
||||
visibleFeatures,
|
||||
}
|
||||
@@ -1106,192 +1106,33 @@ 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). `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.
|
||||
-- — 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.
|
||||
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
|
||||
) 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)
|
||||
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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- UO's localization table: cliloc id -> display string. Items carry a
|
||||
@@ -1327,84 +1168,6 @@ CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
||||
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||
-- is behind the committed artifact.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||
-- NOT applied, because it would remove a facet the site currently serves.
|
||||
--
|
||||
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||
-- blocked by it: the site comes up serving the atlas it already had.
|
||||
--
|
||||
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||
--
|
||||
-- `rejected` is remembered against those exact source hashes so a declined
|
||||
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||
-- hashes and asks again.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||
payload JSON NOT NULL, -- source hashes + facet diff
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
||||
-- per module the operator has installed onto the modules volume, keyed by the
|
||||
-- module id from its module.json — the same id that names the directory, the URL
|
||||
-- segment and the client registry key.
|
||||
--
|
||||
-- This table is a RECORD of what happened, never the source of truth for what is
|
||||
-- mounted: the loader scans the filesystem at require time, before the database is
|
||||
-- reachable (MODULE_API.md §4.1), so the URL surface is a property of the volume
|
||||
-- and not of a row here. What the row decides is whether a mounted module answers
|
||||
-- (`disabled` ⇒ its guard 404s, §4.5) and what the admin panel shows after a
|
||||
-- failure.
|
||||
--
|
||||
-- `state` is the §2.4 machine in one column: installed → enabled → started, with
|
||||
-- disabled and startup_failed as the recoverable states. `installed` is the
|
||||
-- transient state between an install writing the row and the restart that starts
|
||||
-- it. On every boot each non-disabled row is reset to `enabled` and re-attempted
|
||||
-- (so a fixed module recovers on restart, with no panel visit needed), then the
|
||||
-- load outcome writes `started` or `startup_failed`. Only `disabled` survives a
|
||||
-- boot untouched — it is the operator's decision, not an outcome.
|
||||
--
|
||||
-- failure_stage/failure_reason are §4.4's recorded reason, one of the seven
|
||||
-- validation steps of §4.3 plus `boot`. Both are cleared by every transition that
|
||||
-- is not a failure, so a stale reason can never be shown against a running module.
|
||||
--
|
||||
-- source/sha256 are install provenance (§2.5): the release the bundle came from and
|
||||
-- the digest that was verified before unpacking. Both NULL for a directory placed
|
||||
-- on the volume by hand, which stays supported.
|
||||
CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
id VARCHAR(32) NOT NULL PRIMARY KEY, -- module.json id; names the directory
|
||||
name VARCHAR(128) NOT NULL, -- human label for the admin Modules screen
|
||||
version VARCHAR(32) NOT NULL, -- module.json version (semver)
|
||||
state ENUM('installed','enabled','disabled','started','startup_failed')
|
||||
NOT NULL DEFAULT 'installed',
|
||||
failure_stage VARCHAR(32) NULL, -- manifest|core_api|mounts|extensions|schema|require|register|boot
|
||||
failure_reason TEXT NULL, -- the recorded reason, shown in the admin panel
|
||||
source VARCHAR(255) NULL, -- release URL the bundle came from
|
||||
sha256 CHAR(64) NULL, -- verified bundle digest
|
||||
installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME NULL, -- last successful start
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||
"test": "node --test --require ./test/_setup.js"
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [
|
||||
"express",
|
||||
|
||||
@@ -1062,7 +1062,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/shard/link/:account",
|
||||
"handlers": 4,
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
@@ -1947,12 +1947,6 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token",
|
||||
|
||||
@@ -781,10 +781,6 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||
|
||||
@@ -64,16 +64,9 @@ 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`, 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.
|
||||
* 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.
|
||||
*/
|
||||
function mountPath(layer) {
|
||||
const re = layer.regexp
|
||||
@@ -86,11 +79,9 @@ function mountPath(layer) {
|
||||
|
||||
const keys = layer.keys || []
|
||||
let i = 0
|
||||
// `\/` 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) => {
|
||||
src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => {
|
||||
const key = keys[i++]
|
||||
return `${slash ? '/' : ''}:${key ? key.name : 'param'}`
|
||||
return key ? `:${key.name}` : ':param'
|
||||
})
|
||||
|
||||
// Whatever is left should be a literal path with regexp-escaped separators.
|
||||
|
||||
@@ -10,8 +10,6 @@ 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')
|
||||
@@ -21,6 +19,7 @@ const createLogger = require('./utils/logger')
|
||||
const htmlShell = require('./utils/htmlShell')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
const modules = require('./modules/loader')
|
||||
|
||||
const httpLog = createLogger('http')
|
||||
const errLog = createLogger('error')
|
||||
@@ -110,6 +109,34 @@ app.use(
|
||||
}),
|
||||
)
|
||||
|
||||
// ── Installed modules ─────────────────────────────────────────────────
|
||||
// Discovered synchronously from the filesystem, with no database (see
|
||||
// modules/loader.js for why that is not negotiable). The scan has already run by
|
||||
// the time the routers below are required; calling it here makes the ordering
|
||||
// explicit rather than incidental.
|
||||
modules.scan()
|
||||
|
||||
// A module's prebuilt client chunk, served same-origin at /modules/<id>/*.
|
||||
// Same-origin is the whole point: CSP is `script-src 'self'` with no
|
||||
// 'unsafe-inline' (config/csp.js:49), so this loads with no nonce and no import
|
||||
// map — see docs/website/MODULE_API.md §3.1.
|
||||
//
|
||||
// **One static mount PER MODULE, rooted at that module's client dist** — never
|
||||
// one mount over the modules directory. A module holds its server source, its
|
||||
// module.json and its schema fragment alongside the client build; a single
|
||||
// `express.static(modulesDir)` would publish all of it. This serves exactly the
|
||||
// directory the module nominated as its browser bundle and nothing above it.
|
||||
for (const mod of modules.list()) {
|
||||
if (!mod.clientDir || !fs.existsSync(mod.clientDir)) continue
|
||||
app.use(
|
||||
`/modules/${mod.id}`,
|
||||
express.static(mod.clientDir, {
|
||||
index: false,
|
||||
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
||||
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
|
||||
// is generated from route annotations by `npm run swagger` (server/swagger/).
|
||||
@@ -156,77 +183,8 @@ 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' }))
|
||||
|
||||
// Installed modules' prebuilt client chunks, at /modules/<id>/ — same-origin, so
|
||||
// `script-src 'self'` admits them with no nonce and no inline script
|
||||
// (docs/website/MODULE_API.md §3.1). Three properties, each load-bearing:
|
||||
//
|
||||
// • The static root is the directory the ENTRY sits in, never the module root.
|
||||
// One express.static over a module root would publish its server source, its
|
||||
// module.json and its schema fragment; the loader rejects an entry that would
|
||||
// make those the same directory.
|
||||
// • Behind the module's own state guard, so a failed module's chunk is 503 and
|
||||
// a disabled one's is 404 — the same answers its API gives, for the same
|
||||
// reason: the browser should not be running the client half of something the
|
||||
// server half has stopped serving.
|
||||
// • `fallthrough: false`, so a missing file is a 404 here rather than falling
|
||||
// through to the SPA catch-all and answering a `<script src>` with the index
|
||||
// shell, which the browser then rejects on its MIME type instead.
|
||||
//
|
||||
// Vite's library build emits an unhashed `entry.js`, so `no-cache` (revalidate,
|
||||
// not "do not store") is what stops an upgraded module serving yesterday's chunk
|
||||
// out of the disk cache.
|
||||
for (const chunk of modules.clientChunks()) {
|
||||
app.use(
|
||||
chunk.url,
|
||||
chunk.guard,
|
||||
express.static(chunk.dir, {
|
||||
fallthrough: false,
|
||||
setHeaders: (res) => {
|
||||
res.set('Cache-Control', 'no-cache')
|
||||
res.set('X-Content-Type-Options', 'nosniff')
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Everything else under /modules is a 404, not the SPA shell. The namespace
|
||||
// belongs to installed modules' chunks — an unknown module id or a file a module
|
||||
// does not ship is a missing file, and answering a `<script src>` with an HTML
|
||||
// page turns that into a MIME-type refusal in the console with a 200 in the
|
||||
// network tab. It also keeps the namespace's boundary a fact of the app rather
|
||||
// than of whichever catch-all happens to be mounted after it.
|
||||
app.use('/modules', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||
|
||||
// ── /.well-known ──────────────────────────────────────────────────────
|
||||
// Android App Links verification file at the web root (M9 follow-up). Mounted
|
||||
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// ── 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,14 +1,7 @@
|
||||
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||
// ── Push-notification stream catalog + event → stream mapping ───────────────
|
||||
//
|
||||
// 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:
|
||||
// The single source of truth for which streams a user can subscribe to, and how
|
||||
// a shard event maps onto them. 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
|
||||
@@ -24,7 +17,17 @@
|
||||
|
||||
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',
|
||||
@@ -76,10 +79,8 @@ const STREAMS = [
|
||||
},
|
||||
]
|
||||
|
||||
// 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 STREAM_IDS = new Set(STREAMS.map((s) => s.id))
|
||||
const isValidStream = (id) => STREAM_IDS.has(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
|
||||
@@ -161,12 +162,7 @@ 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, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
@@ -1,68 +1,26 @@
|
||||
// ── 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, created_at, updated_at'
|
||||
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
||||
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'
|
||||
|
||||
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
|
||||
// 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 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 = []) {
|
||||
async function create(postId) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||
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]),
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function findByPostId(postId) {
|
||||
@@ -70,38 +28,36 @@ async function findByPostId(postId) {
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
[postId],
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// 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. 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.
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
|
||||
async function findDue(now = new Date(), limit = 25) {
|
||||
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
|
||||
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
|
||||
LIMIT ?`,
|
||||
[now, limit],
|
||||
[now, now, limit],
|
||||
)
|
||||
return attachLegs(rows)
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
// 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)
|
||||
await query(
|
||||
`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],
|
||||
`UPDATE announce_jobs SET
|
||||
${leg}_status = ?,
|
||||
${leg}_attempts = ?,
|
||||
${leg}_last_error = ?,
|
||||
${leg}_next_attempt_at = ?
|
||||
WHERE id = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,4 +65,4 @@ async function setStatus(id, status) {
|
||||
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
|
||||
}
|
||||
|
||||
module.exports = { create, ensureLegs, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
|
||||
@@ -1,38 +1,87 @@
|
||||
// ── Announcement pipeline: pure logic ──────────────────────────────────────
|
||||
//
|
||||
// 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):
|
||||
// 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
|
||||
// • scheduleAfter — exponential backoff schedule + the attempt cap
|
||||
// • 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.
|
||||
// • 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
|
||||
|
||||
// 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 every leg.
|
||||
// the post's admin panel. Shared by both legs.
|
||||
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
|
||||
const MAX_ATTEMPTS = BACKOFF_MS.length
|
||||
|
||||
// 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(/\/+$/, '')
|
||||
// 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 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(base) {
|
||||
return `${String(base || '').replace(/\/+$/, '')}/site/news`
|
||||
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) }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -50,32 +99,29 @@ function scheduleAfter(attempts) {
|
||||
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
|
||||
}
|
||||
|
||||
// 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 : []
|
||||
// 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'
|
||||
const terminal = (s) => s === 'done' || s === 'failed'
|
||||
if (list.every((s) => s === 'done')) return 'done'
|
||||
if (list.every((s) => s === 'failed')) return 'failed'
|
||||
if (list.some(terminal)) return 'partial'
|
||||
if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_LINES,
|
||||
MAX_LINE_LEN,
|
||||
MAX_ATTEMPTS,
|
||||
BACKOFF_MS,
|
||||
baseUrl,
|
||||
buildTownCrierText,
|
||||
articleUrl,
|
||||
legError,
|
||||
classifyTownCrier,
|
||||
classifyDiscord,
|
||||
scheduleAfter,
|
||||
rollupStatus,
|
||||
}
|
||||
|
||||
@@ -2,21 +2,19 @@
|
||||
//
|
||||
// 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 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).
|
||||
// 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.
|
||||
|
||||
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, 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.
|
||||
// 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.
|
||||
async function enqueue(postId) {
|
||||
const jobId = await db.create(postId, registries.announceLegIds())
|
||||
const jobId = await db.create(postId)
|
||||
await posts.linkAnnounceJob(postId, jobId)
|
||||
log.info('announce job enqueued', { jobId, postId })
|
||||
return jobId
|
||||
@@ -45,13 +43,12 @@ async function enqueueIfNeeded(post, transition) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
async function recordOutcome(job, leg, { outcome, error }) {
|
||||
const row = (job.legs || []).find((l) => l.leg === leg)
|
||||
const attempts = Number(row && row.attempts) || 0
|
||||
const attempts = Number(job[`${leg}_attempts`]) || 0
|
||||
|
||||
if (outcome === 'done') {
|
||||
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
|
||||
@@ -74,12 +71,12 @@ async function recordOutcome(job, leg, { outcome, error }) {
|
||||
return refreshStatus(job.id)
|
||||
}
|
||||
|
||||
// Recompute and persist the parent status from the legs; stamp the post's
|
||||
// announced_at the moment every leg has delivered.
|
||||
// Recompute and persist the parent status from the two legs; stamp the post's
|
||||
// announced_at the moment both legs have delivered.
|
||||
async function refreshStatus(jobId) {
|
||||
const job = await db.findById(jobId)
|
||||
if (!job) return null
|
||||
const status = logic.rollupStatus(job.legs.map((l) => l.status))
|
||||
const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
|
||||
if (status !== job.status) await db.setStatus(jobId, status)
|
||||
job.status = status
|
||||
if (status === 'done') {
|
||||
@@ -96,33 +93,16 @@ 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 (!registries.announceLeg(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
if (!db.LEGS.includes(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 })
|
||||
// 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
|
||||
return refreshStatus(job.id)
|
||||
}
|
||||
|
||||
async function getByPostId(postId) {
|
||||
return withLabels(await db.findByPostId(postId))
|
||||
return db.findByPostId(postId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -133,5 +113,4 @@ module.exports = {
|
||||
refreshStatus,
|
||||
resetLeg,
|
||||
getByPostId,
|
||||
withLabels,
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for installed_modules — the module system's record of what is installed and
|
||||
// what happened to it on the last boot (db/schema.sql, docs/website/MODULE_SYSTEM.md
|
||||
// §2.4). Rows are keyed by module id. All state rules live in modules.model.js;
|
||||
// this file only moves rows.
|
||||
|
||||
const COLS = `id, name, version, state, failure_stage, failure_reason,
|
||||
source, sha256, installed_at, started_at, updated_at`
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM installed_modules ORDER BY id`)
|
||||
|
||||
const getOne = (id) => query(`SELECT ${COLS} FROM installed_modules WHERE id = ?`, [id])
|
||||
|
||||
// Write (or refresh) the row for an installed module. A re-install or an upgrade
|
||||
// updates the metadata and deliberately leaves `state` alone: upgrading an enabled
|
||||
// module must not silently disable it, and re-installing a disabled one must not
|
||||
// silently switch it back on. A brand-new row lands in `installed`, the transient
|
||||
// state the next restart resolves.
|
||||
const upsert = ({ id, name, version, source, sha256 }) =>
|
||||
query(
|
||||
`INSERT INTO installed_modules (id, name, version, source, sha256, state)
|
||||
VALUES (?, ?, ?, ?, ?, 'installed')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
version = VALUES(version),
|
||||
source = VALUES(source),
|
||||
sha256 = VALUES(sha256)`,
|
||||
[id, name, version, source ?? null, sha256 ?? null],
|
||||
)
|
||||
|
||||
// Move one row to a new state. `failureStage`/`failureReason` are written on every
|
||||
// call — a non-failing transition passes nulls, which is what clears a stale reason
|
||||
// off a module that has since come up. `stampStarted` sets started_at to now.
|
||||
const setState = ({ id, state, failureStage = null, failureReason = null, stampStarted = false }) =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = ?, failure_stage = ?, failure_reason = ?
|
||||
${stampStarted ? ', started_at = CURRENT_TIMESTAMP' : ''}
|
||||
WHERE id = ?`,
|
||||
[state, failureStage, failureReason, id],
|
||||
)
|
||||
|
||||
// Boot reset: every row the operator has not disabled goes back to `enabled` with
|
||||
// no failure recorded, so the load that follows writes this boot's outcome rather
|
||||
// than leaving the last one on display. `disabled` is untouched — it is a decision,
|
||||
// not an outcome.
|
||||
const resetForBoot = () =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = 'enabled', failure_stage = NULL, failure_reason = NULL
|
||||
WHERE state <> 'disabled'`,
|
||||
)
|
||||
|
||||
// Drop the row entirely. Only the explicit purge does this (§2.5); a plain
|
||||
// uninstall disables the module and keeps its row and its data.
|
||||
const remove = (id) => query('DELETE FROM installed_modules WHERE id = ?', [id])
|
||||
|
||||
module.exports = { listAll, getOne, upsert, setState, resetForBoot, remove }
|
||||
@@ -1,183 +0,0 @@
|
||||
// The module state machine (docs/website/MODULE_SYSTEM.md §2.4, MODULE_API.md §4.4).
|
||||
//
|
||||
// installed ──► enabled ──► started
|
||||
// │ │
|
||||
// │ └──► startup_failed ──┐
|
||||
// │ │ (retry)
|
||||
// └──────────────► disabled ◄─────────┘
|
||||
//
|
||||
// One row per installed module, one column holding the state. The rules that make
|
||||
// the machine mean anything live here, not in the SQL:
|
||||
//
|
||||
// - `installed` is transient. An install writes the row; the restart that follows
|
||||
// resolves it to `started` or `startup_failed` (§2.5).
|
||||
// - `disabled` is the only state a boot leaves alone. It is the operator's
|
||||
// decision; every other state is an outcome and is recomputed each boot by
|
||||
// beginBoot(). That is what makes a fixed module recover on restart without
|
||||
// anyone visiting the admin panel.
|
||||
// - A failure is recorded with the stage it happened at, and every non-failing
|
||||
// transition clears it — a running module can never show a stale reason.
|
||||
//
|
||||
// What this table does NOT decide is which routes exist. The loader scans the
|
||||
// filesystem at require time, before the database is reachable (MODULE_API.md §4.1),
|
||||
// so a disabled module is still mounted and simply guarded (§4.5). Keeping the URL
|
||||
// surface a property of the volume is what lets routes.manifest.json be generated
|
||||
// off a dead database.
|
||||
|
||||
const db = require('./modules.db')
|
||||
|
||||
const STATES = ['installed', 'enabled', 'disabled', 'started', 'startup_failed']
|
||||
|
||||
// The stage a failure happened at: MODULE_API.md §4.3's seven validation steps,
|
||||
// plus `boot` for an onBoot hook that threw (§2.5).
|
||||
const FAILURE_STAGES = [
|
||||
'manifest',
|
||||
'core_api',
|
||||
'mounts',
|
||||
'extensions',
|
||||
'schema',
|
||||
'require',
|
||||
'register',
|
||||
'boot',
|
||||
]
|
||||
|
||||
class ModuleStateError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message)
|
||||
this.name = 'ModuleStateError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// Legal moves, keyed by target state. Anything not listed is a bug in the caller
|
||||
// and throws rather than writing a row that misrepresents what happened.
|
||||
const ALLOWED_FROM = {
|
||||
// Enabling is the recovery path as well as the first step: a disabled module the
|
||||
// operator switches back on, and a startup_failed one they retry, both land here.
|
||||
enabled: ['installed', 'enabled', 'disabled', 'startup_failed', 'started'],
|
||||
// The operator may disable a module in any state, including one that is running.
|
||||
disabled: STATES,
|
||||
// Reached from `enabled` on a normal boot, and from `installed` on the first boot
|
||||
// after an install (or for a directory placed on the volume by hand, whose row is
|
||||
// written moments earlier in the same boot).
|
||||
started: ['installed', 'enabled'],
|
||||
// Failure always precedes `started` in the lifecycle; `started` is accepted so a
|
||||
// late failure can still be recorded truthfully rather than dropped.
|
||||
startup_failed: ['installed', 'enabled', 'started'],
|
||||
}
|
||||
|
||||
// row → API shape.
|
||||
function serialize(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
state: row.state,
|
||||
failureStage: row.failure_stage ?? null,
|
||||
failureReason: row.failure_reason ?? null,
|
||||
source: row.source ?? null,
|
||||
sha256: row.sha256 ?? null,
|
||||
installedAt: row.installed_at ?? null,
|
||||
startedAt: row.started_at ?? null,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(serialize)
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const rows = await db.getOne(id)
|
||||
return serialize(rows[0])
|
||||
}
|
||||
|
||||
// Record an install (or a re-install / upgrade). Metadata is refreshed; the state is
|
||||
// left as it is, so upgrading an enabled module does not switch it off and
|
||||
// re-installing a disabled one does not switch it on. A new row lands in `installed`.
|
||||
async function recordInstalled({ id, name, version, source = null, sha256 = null }) {
|
||||
if (!id || !name || !version) {
|
||||
throw new ModuleStateError('invalid_module', 'id, name and version are required')
|
||||
}
|
||||
await db.upsert({ id, name, version, source, sha256 })
|
||||
return get(id)
|
||||
}
|
||||
|
||||
// Start of boot: clear the last boot's outcomes so what is on display after this
|
||||
// boot is what this boot did. Leaves `disabled` rows alone (see the header).
|
||||
// Returns the number of rows reset.
|
||||
async function beginBoot() {
|
||||
const res = await db.resetForBoot()
|
||||
return res?.affectedRows ?? 0
|
||||
}
|
||||
|
||||
// Apply one transition, after checking it is legal for the row's current state.
|
||||
// A row that does not exist is not an error the caller can act on — a module can be
|
||||
// present on the volume with no row at all — so it returns null and writes nothing.
|
||||
async function transition(id, target, { failureStage = null, failureReason = null } = {}) {
|
||||
const current = await get(id)
|
||||
if (!current) return null
|
||||
|
||||
const allowed = ALLOWED_FROM[target]
|
||||
if (!allowed.includes(current.state)) {
|
||||
throw new ModuleStateError(
|
||||
'illegal_transition',
|
||||
`module '${id}': cannot move from '${current.state}' to '${target}'`,
|
||||
)
|
||||
}
|
||||
|
||||
await db.setState({
|
||||
id,
|
||||
state: target,
|
||||
failureStage,
|
||||
failureReason,
|
||||
stampStarted: target === 'started',
|
||||
})
|
||||
return get(id)
|
||||
}
|
||||
|
||||
const enable = (id) => transition(id, 'enabled')
|
||||
const disable = (id) => transition(id, 'disabled')
|
||||
const markStarted = (id) => transition(id, 'started')
|
||||
|
||||
// Record a failure at a named stage. Two deliberate softenings, both because this is
|
||||
// called from the boot path where throwing would turn one module's failure into
|
||||
// everybody's (MODULE_API.md §4.4 — the failing module fails alone):
|
||||
//
|
||||
// - a `disabled` row is a no-op. The operator switched it off; a broken module
|
||||
// they already disabled is not news, and overwriting their decision with an
|
||||
// outcome would silently re-enable it on the next boot.
|
||||
// - an unrecognised stage is recorded as `require` rather than rejected, so a
|
||||
// miscategorised failure still reaches the admin panel with its reason intact.
|
||||
async function markStartupFailed(id, { stage, reason }) {
|
||||
const current = await get(id)
|
||||
if (!current || current.state === 'disabled') return current
|
||||
|
||||
return transition(id, 'startup_failed', {
|
||||
failureStage: FAILURE_STAGES.includes(stage) ? stage : 'require',
|
||||
failureReason: String(reason ?? 'unknown error').slice(0, 4000),
|
||||
})
|
||||
}
|
||||
|
||||
// Purge only (§2.5). A plain uninstall disables the module and keeps its row, so its
|
||||
// data survives and the admin panel can still show what was there.
|
||||
async function remove(id) {
|
||||
await db.remove(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STATES,
|
||||
FAILURE_STAGES,
|
||||
ModuleStateError,
|
||||
list,
|
||||
get,
|
||||
recordInstalled,
|
||||
beginBoot,
|
||||
enable,
|
||||
disable,
|
||||
markStarted,
|
||||
markStartupFailed,
|
||||
remove,
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
// Per-user push-notification subscriptions (which streams a user opted into;
|
||||
// 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).
|
||||
// applied to every device they register). The catalog is config/notificationStreams.
|
||||
|
||||
const db = require('./notificationSubs.db')
|
||||
const { isValidStream } = require('../../modules/registries')
|
||||
const { isValidStream } = require('../../config/notificationStreams')
|
||||
|
||||
const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// ── 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,7 +1,9 @@
|
||||
// ── The module loader ──────────────────────────────────────────────────────
|
||||
//
|
||||
// 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.
|
||||
// SPIKE (docs/website/MODULE_SYSTEM.md §2.7 Phase 1). This is the smallest
|
||||
// loader that can carry /api/v1/public/atlas/* out of core and prove the
|
||||
// contract in docs/website/MODULE_API.md. Phase 2 rebuilds it properly with the
|
||||
// installed_modules table, the full state machine and the admin panel behind it.
|
||||
//
|
||||
// The one property this file exists to guarantee, and the reason it looks the
|
||||
// way it does:
|
||||
@@ -10,37 +12,18 @@
|
||||
// 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 (§4.1).
|
||||
// invisible to the frozen-URL-surface test (§1.12). So: readdirSync at
|
||||
// require time, no database, no promises.
|
||||
//
|
||||
// A module that fails ANYWHERE in this file fails alone. Nothing here may throw
|
||||
// past its own try/catch — a bad module must cost the site its routes, never its
|
||||
// boot (§4.4).
|
||||
//
|
||||
// PR 7 added the client half's server-side end: validating `client.entry` and
|
||||
// publishing where the chunk lives (`clientChunks()`), so app.js can serve it and
|
||||
// utils/htmlShell.js can inject its script tag. The loader resolves and validates;
|
||||
// it does not mount, because the chunk hangs off the ROOT app rather than a tier
|
||||
// router, and app.js is where core's own static mounts live.
|
||||
//
|
||||
// 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.
|
||||
// boot.
|
||||
|
||||
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')
|
||||
|
||||
@@ -58,26 +41,35 @@ const MANIFEST_KEYS = new Set([
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||
])
|
||||
|
||||
// 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.
|
||||
// Prefixes core itself owns, per tier. A module may not take one of these.
|
||||
// Hardcoded for the spike; Phase 2 derives it from the tier mount tables so it
|
||||
// cannot drift the first time core adds a capability router.
|
||||
const CORE_PREFIXES = {
|
||||
public: ['/posts', '/wiki', '/pages', '/shard'],
|
||||
admin: [
|
||||
'/account', '/users', '/invites', '/auth', '/moderation', '/bot-activity',
|
||||
'/activity', '/posts', '/uploads', '/wiki', '/pages', '/shard', '/uo-link',
|
||||
'/email', '/discord-bot', '/settings',
|
||||
],
|
||||
player: ['/account', '/shard', '/appeals'],
|
||||
}
|
||||
|
||||
// id → record. Populated by load(), read by list().
|
||||
// id → record. Populated by scan(), read by mountInto/boot/shutdown/list.
|
||||
const modules = new Map()
|
||||
let loaded = false
|
||||
let scanned = false
|
||||
|
||||
// ── ctx ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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.
|
||||
// Everything a module may reach in core, and nothing else (MODULE_API.md §2.3).
|
||||
// Required lazily inside the factory rather than at file scope: this module is
|
||||
// required by app.js, and hoisting these to the top would make the DB pool, the
|
||||
// settings model and the upload directory startup-time dependencies of the
|
||||
// loader itself.
|
||||
function buildCtx(id, moduleRoot) {
|
||||
/* eslint-disable global-require */
|
||||
// The shared SERVER dependencies — the exact counterpart of window.__rg's
|
||||
// react/react-dom/react-router on the client, and load-bearing for the same
|
||||
// two reasons (§7.2).
|
||||
// two reasons.
|
||||
//
|
||||
// 1. A module lives at <repo>/modules/<id>/, OUTSIDE server/, so Node's
|
||||
// resolver walks up from there and never sees server/node_modules. A
|
||||
@@ -105,9 +97,9 @@ function buildCtx(id, moduleRoot) {
|
||||
const uploads = require('../router/v1/admin/imageUpload')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// 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.
|
||||
// Narrowed on purpose (MODULE_API.md §2.3): utils/auth also re-exports
|
||||
// signToken/setAuthCookie/the TOTP challenge primitives, and minting a session
|
||||
// is core's job. A module that needs an identity needs to READ one.
|
||||
const ctx = {
|
||||
moduleId: id,
|
||||
paths: { moduleRoot },
|
||||
@@ -151,11 +143,6 @@ 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')
|
||||
@@ -169,50 +156,26 @@ function buildApi(record) {
|
||||
}
|
||||
}
|
||||
},
|
||||
// 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)
|
||||
// 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
|
||||
},
|
||||
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
|
||||
@@ -239,195 +202,67 @@ function coreTableNames() {
|
||||
return coreTables
|
||||
}
|
||||
|
||||
// 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'])
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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()
|
||||
function checkTableNames(dir, manifest) {
|
||||
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 checkTableNames(id, tables) {
|
||||
const allowed = LEGACY_TABLE_PREFIXES[id] || []
|
||||
const allowed = LEGACY_TABLE_PREFIXES[manifest.id] || []
|
||||
const core = coreTableNames()
|
||||
|
||||
for (const table of tables) {
|
||||
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.has(table)) {
|
||||
if (other.tables && 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}_"`)
|
||||
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}_"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
)
|
||||
/** Record which tables a module owns, so the next module can be checked against it. */
|
||||
function tablesOf(dir, manifest) {
|
||||
if (!manifest.schema) return new Set()
|
||||
const sql = fs.readFileSync(path.join(dir, manifest.schema), 'utf8')
|
||||
return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
|
||||
}
|
||||
|
||||
// ── The client chunk ───────────────────────────────────────────────────────
|
||||
|
||||
// A chunk filename, and the same character set utils/htmlShell.js will accept in
|
||||
// a script src. Two copies of the rule, deliberately: this one rejects the module
|
||||
// at load time, that one refuses to write the tag. A validator three files away
|
||||
// staying strict is not something an HTML attribute should depend on.
|
||||
const CHUNK_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
|
||||
|
||||
/**
|
||||
* Resolve and validate `client.entry` — where a module's prebuilt chunk lives on
|
||||
* disk, and the URL it is served at (§3.1).
|
||||
*
|
||||
* The rule that matters most is the last one, and it is the one a reviewer would
|
||||
* not think to ask for: the static mount is rooted at the DIRECTORY THE ENTRY IS
|
||||
* IN, not at the module root. One `express.static` over a module root would
|
||||
* publish its server source, its `module.json` and its schema fragment to the
|
||||
* internet. So an entry sitting directly in the module root is rejected rather
|
||||
* than quietly turning the whole module into a public directory.
|
||||
*
|
||||
* @returns {{dir: string, url: string, entryUrl: string}|null} null when the
|
||||
* module ships no client half — a server-only module is perfectly normal.
|
||||
*/
|
||||
function resolveClient(dir, id, manifest) {
|
||||
// Absent `client` is a server-only module. Present but empty is not the same
|
||||
// thing: it states a client half and delivers none, which would be a module
|
||||
// whose pages never load and nothing anywhere saying why.
|
||||
if (manifest.client === undefined) return null
|
||||
const { entry } = manifest.client
|
||||
if (typeof entry !== 'string' || !entry.trim()) fail('manifest', 'client.entry must be a path')
|
||||
|
||||
const file = path.resolve(dir, entry)
|
||||
// Containment before anything else: `../../server/src/config` resolves to a
|
||||
// real, readable directory, and every check below it would pass.
|
||||
if (file !== dir && !file.startsWith(dir + path.sep)) {
|
||||
fail('manifest', `client.entry "${entry}" escapes the module directory`)
|
||||
}
|
||||
if (!CHUNK_FILE.test(path.basename(file))) {
|
||||
fail('manifest', `client.entry "${entry}" must name a .js file`)
|
||||
}
|
||||
const chunkDir = path.dirname(file)
|
||||
if (chunkDir === dir) {
|
||||
fail('manifest', `client.entry "${entry}" must be in a subdirectory — its directory is served`)
|
||||
}
|
||||
if (!fs.existsSync(file)) fail('manifest', `client.entry "${entry}" is missing`)
|
||||
|
||||
return {
|
||||
dir: chunkDir,
|
||||
url: `/modules/${id}`,
|
||||
entryUrl: `/modules/${id}/${path.basename(file)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(dir, id, tierRouters) {
|
||||
function readManifest(dir, id) {
|
||||
const file = path.join(dir, 'module.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
|
||||
for (const key of Object.keys(manifest)) {
|
||||
// Rejected, not ignored: a typo'd key must be a loud failure rather than a
|
||||
// silently inert setting the operator believes they configured.
|
||||
if (!MANIFEST_KEYS.has(key)) fail('manifest', `unknown key "${key}" in module.json`)
|
||||
if (!MANIFEST_KEYS.has(key)) throw new Error(`unknown key "${key}" in module.json`)
|
||||
}
|
||||
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 (!ID.test(manifest.id || '')) throw new Error(`invalid id "${manifest.id}"`)
|
||||
if (manifest.id !== id) throw new Error(`id "${manifest.id}" does not match directory "${id}"`)
|
||||
if (!manifest.version) throw new Error('missing version')
|
||||
if (!manifest.coreApi) throw new Error('missing coreApi')
|
||||
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
|
||||
fail('core_api', `needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
|
||||
throw new Error(`needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
|
||||
}
|
||||
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
|
||||
if (!TIERS.includes(tier)) fail('mounts', `unknown tier "${tier}" in mounts`)
|
||||
for (const prefix of prefixes) {
|
||||
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)) {
|
||||
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.client !== undefined) {
|
||||
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
|
||||
fail('manifest', 'client must be an object')
|
||||
}
|
||||
for (const key of Object.keys(manifest.client)) {
|
||||
if (key !== 'entry') fail('manifest', `unknown key "client.${key}" in module.json`)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
throw new Error('declares schema but no purge')
|
||||
}
|
||||
if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) {
|
||||
fail('schema', `purge file "${manifest.purge}" is missing`)
|
||||
if (manifest.schema) checkTableNames(dir, manifest)
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
|
||||
if (!TIERS.includes(tier)) throw new Error(`unknown tier "${tier}" in mounts`)
|
||||
for (const prefix of prefixes) {
|
||||
if (!PREFIX.test(prefix)) throw new Error(`bad prefix "${prefix}" in mounts.${tier}`)
|
||||
if (CORE_PREFIXES[tier].includes(prefix)) throw new Error(`prefix ${tier}${prefix} is owned by core`)
|
||||
for (const other of modules.values()) {
|
||||
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
|
||||
throw new Error(`prefix ${tier}${prefix} already registered by module "${other.id}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -443,35 +278,16 @@ function checkDeclared(record) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
// ── Scan ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Discover, validate and register every module under MODULES_DIR. Synchronous,
|
||||
* filesystem-only, and safe to call when the directory does not exist. Called
|
||||
* once from app.js at require time; a second call is a no-op.
|
||||
*/
|
||||
function 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
|
||||
function scan() {
|
||||
if (scanned) return
|
||||
scanned = true
|
||||
|
||||
let entries = []
|
||||
try {
|
||||
@@ -493,39 +309,23 @@ function load(tierRouters) {
|
||||
dir,
|
||||
manifest: null,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
staged: registries.stage(id),
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
onBoot: null,
|
||||
onShutdown: 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, tierRouters)
|
||||
record.client = resolveClient(dir, id, record.manifest)
|
||||
stage = 'schema'
|
||||
record.manifest = readManifest(dir, id)
|
||||
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`)
|
||||
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))
|
||||
register(buildCtx(id, dir), buildApi(record))
|
||||
checkDeclared(record)
|
||||
}
|
||||
record.state = 'registered'
|
||||
@@ -537,261 +337,159 @@ function load(tierRouters) {
|
||||
// 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`, {
|
||||
stage: record.stage,
|
||||
reason: err.message,
|
||||
})
|
||||
log.error(`module "${id}" failed to load — continuing without it`, { 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.
|
||||
// ── Mounting ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mount every registered module's routers for one tier onto that tier's router.
|
||||
* Called from router/v1/{public,admin,player}/index.js, after core's own mounts
|
||||
* so a module can never shadow a core prefix even if the collision check above
|
||||
* were somehow bypassed.
|
||||
*/
|
||||
function mountInto(tier, tierRouter) {
|
||||
scan()
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered' && record.state !== 'started') continue
|
||||
for (const [prefix, router] of record.routes[tier]) {
|
||||
// The dispatch guard. A module that failed AFTER mounting (schema replay,
|
||||
// onBoot) keeps its URLs — so routes.manifest.json does not depend on
|
||||
// whether a boot hook happened to succeed on the generating machine — but
|
||||
// answers 503 rather than serving half-initialised data (§4.4).
|
||||
tierRouter.use(prefix, (req, res, next) => {
|
||||
if (record.state === 'startup_failed') {
|
||||
return res.status(503).json({ message: 'Module unavailable' })
|
||||
}
|
||||
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
|
||||
return next()
|
||||
}, router)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read every registered module's schema fragment, in scan order. */
|
||||
function schemaFragments() {
|
||||
scan()
|
||||
const out = []
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered' || !record.manifest.schema) continue
|
||||
const file = path.join(record.dir, record.manifest.schema)
|
||||
try {
|
||||
out.push({ id: record.id, sql: fs.readFileSync(file, 'utf8') })
|
||||
} catch (err) {
|
||||
markFailed(record.id, `schema fragment unreadable: ${err.message}`)
|
||||
log.error(`module "${record.id}" schema fragment unreadable`, { reason: err.message })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a module to `startup_failed` with a reason. Called by whoever ran the
|
||||
* step that failed — ensureSchema() replays the fragments, so it is the only
|
||||
* thing that can know a fragment threw.
|
||||
*
|
||||
* Failing here is a POST-mount failure: the routes stay mounted and the dispatch
|
||||
* guard turns them into 503s, which is what keeps routes.manifest.json
|
||||
* independent of whether a boot step succeeded on the generating machine (§4.4).
|
||||
*/
|
||||
function markFailed(id, reason) {
|
||||
const record = modules.get(id)
|
||||
if (!record) return
|
||||
record.state = 'startup_failed'
|
||||
record.reason = reason
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every registered module's onBoot. Called from server.js AFTER
|
||||
* ensureSchema() and seedDefaults() (so a module's own tables exist) and BEFORE
|
||||
* the listener binds. Individually try/caught: a hook that throws costs that
|
||||
* module its `started` state and nothing else.
|
||||
*/
|
||||
async function boot() {
|
||||
scan()
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
// 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)
|
||||
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.stage = 'register'
|
||||
record.reason = err.message
|
||||
log.error(`module "${record.id}" failed to register — continuing without it`, {
|
||||
record.reason = `onBoot: ${err.message}`
|
||||
log.error(`module "${record.id}" onBoot failed — its routes will answer 503`, {
|
||||
reason: err.message,
|
||||
})
|
||||
continue // unmounted, exactly like a validation failure in the first pass
|
||||
}
|
||||
mount(record, tierRouters)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 mount(record, tierRouters) {
|
||||
for (const tier of TIERS) {
|
||||
for (const [prefix, router] of record.routes[tier]) {
|
||||
tierRouters[tier].use(prefix, stateGuard(record), router)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dispatch guard, as a middleware over the LIVE record.
|
||||
*
|
||||
* A closure over the record rather than over its state: everything mounts once,
|
||||
* at boot, and the states that matter here are reached afterwards — the schema
|
||||
* replay fails, `onBoot` throws, an admin disables the module. A guard that read
|
||||
* the state at mount time would answer for the state a module was in before any
|
||||
* of that happened.
|
||||
*
|
||||
* Used for a module's API routes and, since PR 7, for its client chunk: a module
|
||||
* answering 503 on its API must not also be handing the browser the script that
|
||||
* calls it, and one an admin has disabled should be as absent from the page as it
|
||||
* is from the nav.
|
||||
*/
|
||||
function stateGuard(record) {
|
||||
return (req, res, next) => {
|
||||
if (record.state === 'startup_failed') {
|
||||
return res.status(503).json({ message: 'Module unavailable' })
|
||||
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 })
|
||||
}
|
||||
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 loaded module to a new state — the POST-mount transitions.
|
||||
*
|
||||
* 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 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 = 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()`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Has load() run in this process?
|
||||
* What GET /api/v1/public/modules, the HTML shell and the admin panel read.
|
||||
*
|
||||
* 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).
|
||||
* `clientDir` and `entryUrl` are split deliberately: app.js needs the absolute
|
||||
* directory to serve statically, and it must be the DIST directory rather than
|
||||
* the module root — a module keeps its server source, its module.json and its
|
||||
* schema fragment alongside the client build, and one static mount over the
|
||||
* module root would publish all of them.
|
||||
*/
|
||||
function list() {
|
||||
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) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module that ships a client chunk, with where to serve it from and the
|
||||
* guard to serve it behind — in scan order.
|
||||
*
|
||||
* Listed regardless of state, because mounting happens once at boot and the
|
||||
* guard is what answers for the state at request time (the same arrangement the
|
||||
* API routes have). A module that failed VALIDATION never reaches here at all:
|
||||
* `record.client` is only resolved once the manifest passed.
|
||||
*
|
||||
* `dir` is the directory the entry sits in, never the module root — see
|
||||
* resolveClient. app.js does the mounting; this file does not know about the
|
||||
* root app.
|
||||
*
|
||||
* @returns {{id: string, dir: string, url: string, entryUrl: string, guard: Function}[]}
|
||||
*/
|
||||
function clientChunks() {
|
||||
assertLoaded('clientChunks')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.client)
|
||||
.map((r) => ({ id: r.id, ...r.client, guard: stateGuard(r) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The script URLs the HTML shell should inject, in scan order.
|
||||
*
|
||||
* `started` only, and that is the difference between this and clientChunks():
|
||||
* the mount is a standing offer answered by a guard, while the tag is a decision
|
||||
* taken per page render, when the state is already known. A module whose `onBoot`
|
||||
* failed keeps its URLs and answers 503 on them — loading its client half would
|
||||
* render its pages against a backend that cannot serve them.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function clientEntryUrls() {
|
||||
assertLoaded('clientEntryUrls')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.client && r.state === 'started')
|
||||
.map((r) => r.client.entryUrl)
|
||||
scan()
|
||||
return [...modules.values()].map((r) => {
|
||||
const entry = r.manifest.client && r.manifest.client.entry
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.manifest.name || r.id,
|
||||
version: r.manifest.version,
|
||||
state: r.state,
|
||||
reason: r.reason,
|
||||
capabilities: r.manifest.capabilities || [],
|
||||
// e.g. entry "client/dist/entry.js" → dir <root>/client/dist, url /modules/uo/entry.js
|
||||
clientDir: entry ? path.join(r.dir, path.dirname(entry)) : null,
|
||||
entryUrl: entry ? `/modules/${r.id}/${path.basename(entry)}` : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Absolute path of the modules directory. */
|
||||
const dir = () => MODULES_DIR
|
||||
|
||||
module.exports = {
|
||||
load,
|
||||
list,
|
||||
setState,
|
||||
fragments,
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
clientChunks,
|
||||
clientEntryUrls,
|
||||
isLoaded,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
// ── 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,
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// ── 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,23 +728,6 @@ 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)) {
|
||||
@@ -955,7 +938,6 @@ module.exports = {
|
||||
ASSET_RULES,
|
||||
listActivity,
|
||||
listUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
|
||||
@@ -15,6 +15,7 @@ const express = require('express')
|
||||
|
||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const modules = require('../../../modules/loader')
|
||||
|
||||
const accountRouter = require('./account.router')
|
||||
const usersRouter = require('./users.router')
|
||||
@@ -74,6 +75,11 @@ adminRouter.use('/email', emailRouter)
|
||||
adminRouter.use('/discord-bot', discordBotRouter)
|
||||
adminRouter.use('/settings', settingsRouter)
|
||||
|
||||
// Installed modules' admin routers. Already behind this group's
|
||||
// noindex/isLoggedIn/staffOnly gate — a module adds per-route gates on top and
|
||||
// never re-implements the tier gate (docs/website/MODULE_API.md §2.4).
|
||||
modules.mountInto('admin', adminRouter)
|
||||
|
||||
// The two singletons that own no path segment of their own: GET /dashboard and
|
||||
// PUT /site-mode. Mounted at the group root, last, exactly where the residual
|
||||
// admin.routes.js used to sit — safe because dashboard.router.js declares no
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus
|
||||
// the announcement pipeline status and retry.
|
||||
// the announcement pipeline (town crier + Discord) 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,7 +13,6 @@ 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()
|
||||
|
||||
@@ -125,17 +124,14 @@ postsRouter.get(
|
||||
postsRouter.post(
|
||||
'/:id/announce/retry',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Retry one announcement delivery leg'
|
||||
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||||
// #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", description: "A registered delivery leg id, as returned by GET /announce." } }, required: ["leg"] } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, 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(),
|
||||
// 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'),
|
||||
body('leg').isIn(['towncrier', 'discord']),
|
||||
validate,
|
||||
ctrl.retryAnnounceLeg,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,17 @@
|
||||
// admin needs to be told what is wrong with their path, and a 500 says only
|
||||
// "something broke".
|
||||
|
||||
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
|
||||
// ⚠ SPIKE ARTIFACT — core reaching INTO a module. Phase 1 carries only the
|
||||
// PUBLIC atlas routes out of core (MODULE_SYSTEM.md §2.7); these five admin
|
||||
// routes live at /admin/shard/atlas/*, inside the `/shard` prefix that core
|
||||
// still owns, so the module cannot take them without either colliding with core
|
||||
// or changing a URL — and routes.manifest.json must not move.
|
||||
//
|
||||
// So this one import crosses the boundary in the core → module direction. It is
|
||||
// not the direction the zero-imports rule forbids (a module must not reach into
|
||||
// core), but it is still wrong, and it is precisely what Phase 3 fixes by moving
|
||||
// the whole `/shard` admin prefix at once. Recorded here rather than hidden.
|
||||
const atlas = require('../../../../../modules/uo/server/model/shardAtlas/shardAtlas.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-shard-atlas')
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
|
||||
// and moderators manage content and reports, never accounts.
|
||||
//
|
||||
// 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.
|
||||
// Handlers still live in admin.controller.js (users) and usersShard.controller.js
|
||||
// (uo-link footprint); this PR re-wires routes, not logic.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const registries = require('../../../modules/registries')
|
||||
const usersShard = require('./usersShard.controller')
|
||||
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')
|
||||
|
||||
@@ -149,6 +151,11 @@ 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']
|
||||
@@ -159,21 +166,84 @@ usersRouter.get(
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
ctrl.getUser,
|
||||
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,
|
||||
)
|
||||
|
||||
// ── 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,6 +25,18 @@ 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 {
|
||||
@@ -127,4 +139,4 @@ async function unlinkAccount(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// ── 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 registries = require('../../../modules/registries')
|
||||
const { STREAMS } = require('../../../config/notificationStreams')
|
||||
const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
|
||||
|
||||
const log = require('../../../utils/logger')('notifications')
|
||||
@@ -49,11 +49,9 @@ async function removeDevice(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// GET /auth/me/notifications/streams — the subscribable catalog (static).
|
||||
function getStreams(req, res) {
|
||||
return res.json({ streams: registries.allStreams() })
|
||||
return res.json({ streams: STREAMS })
|
||||
}
|
||||
|
||||
// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.
|
||||
|
||||
@@ -21,6 +21,7 @@ const express = require('express')
|
||||
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const modules = require('../../../modules/loader')
|
||||
|
||||
const accountRouter = require('./account.router')
|
||||
const shardRouter = require('./shard.router')
|
||||
@@ -40,4 +41,8 @@ playerRouter.use('/account', accountRouter)
|
||||
playerRouter.use('/shard', shardRouter)
|
||||
playerRouter.use('/appeals', appealsRouter)
|
||||
|
||||
// Installed modules' player routers, already behind this group's
|
||||
// noindex/requireAuth gate.
|
||||
modules.mountInto('player', playerRouter)
|
||||
|
||||
module.exports = playerRouter
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const modules = require('../../../modules/loader')
|
||||
|
||||
const postsRouter = require('./posts.router')
|
||||
const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const shardRouter = require('./shard.router')
|
||||
const atlasRouter = require('./atlas.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
const publicRouter = express.Router()
|
||||
@@ -34,17 +34,16 @@ publicRouter.use('/wiki', wikiRouter)
|
||||
publicRouter.use('/pages', pagesRouter)
|
||||
// Live shard data, never site-mode gated.
|
||||
publicRouter.use('/shard', shardRouter)
|
||||
// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree
|
||||
// rather than fetched from the sidecar. Deliberately not under /shard — nothing
|
||||
// here depends on the bridge — and site-mode gated per route like the content
|
||||
// routers above, which is the other half of that distinction.
|
||||
publicRouter.use('/atlas', atlasRouter)
|
||||
// What this backend serves beyond core. A real prefix layer rather than a fifth
|
||||
// singleton in site.router.js, because the loader's prefix-collision probe reads
|
||||
// the live tier stack and skips root-mounted layers — this mount is what makes
|
||||
// /modules unclaimable by a module. Never site-mode gated: a client must be able
|
||||
// to feature-detect while the site is in maintenance.
|
||||
publicRouter.use('/modules', modulesRouter)
|
||||
|
||||
// Installed modules' public routers, each at the prefix it declared in its
|
||||
// module.json. Mounted AFTER core's own prefixes so a module can never shadow
|
||||
// one even if the loader's collision check were somehow bypassed, and BEFORE the
|
||||
// root-mounted siteRouter below for the same reason that one is mounted last.
|
||||
//
|
||||
// The spawn atlas used to sit here as `/atlas`; it is now module-uo's, which is
|
||||
// what the Phase 1 spike is proving (docs/website/MODULE_API.md). The URL is
|
||||
// unchanged — routes.manifest.json is the proof.
|
||||
modules.mountInto('public', publicRouter)
|
||||
|
||||
// The four singletons that own no path segment of their own: /settings, /status,
|
||||
// /version and /contact. Mounted at the group root, last — safe only because
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// Public · Modules — what this backend is currently serving beyond core.
|
||||
//
|
||||
// Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md §2.7. The published shape is
|
||||
// settled in MODULE_API.md §2.1 (`capabilities` are opaque strings, published
|
||||
// here, for clients to feature-detect against).
|
||||
//
|
||||
// Two decisions are visible in the ten lines below and are the whole of this
|
||||
// file's design:
|
||||
//
|
||||
// • **`started` only.** The public surface answers "what is serving", and
|
||||
// nothing else. A module that failed to load, or that an operator disabled,
|
||||
// is simply ABSENT — the same treatment §4.4 already gives its routes and
|
||||
// its nav, so an anonymous visitor sees a site without that capability
|
||||
// rather than a site advertising a capability that 503s. `state`, the
|
||||
// failure stage and the failure reason are core's business and belong to the
|
||||
// admin Modules screen; none of the three is published here.
|
||||
// • **No database, and no siteMode gate.** The answer comes from the loader's
|
||||
// in-memory records, so this endpoint keeps working with the database down —
|
||||
// the same class as /public/version and /public/status, both of which must
|
||||
// answer during maintenance so a client can bootstrap and render the
|
||||
// maintenance page. A client that could not feature-detect while the site
|
||||
// was in maintenance would render its maintenance page as though no module
|
||||
// existed.
|
||||
//
|
||||
// This endpoint is deliberately NOT how a module's client chunk gets loaded.
|
||||
// `utils/htmlShell.js` injects a `<script type="module">` per started module
|
||||
// (MODULE_API.md §3.1.3), so the browser is handed the tag rather than a URL to
|
||||
// go and fetch; there is no `client` field here for the same reason there is no
|
||||
// second copy of any other fact. See MODULE_SYSTEM.md §2.6, amended to match.
|
||||
|
||||
const loader = require('../../../modules/loader')
|
||||
|
||||
const log = require('../../../utils/logger')('public:modules')
|
||||
|
||||
/** id, name, version and capabilities — everything else the loader knows is internal. */
|
||||
const publish = (m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
capabilities: m.capabilities,
|
||||
})
|
||||
|
||||
function getModules(req, res) {
|
||||
try {
|
||||
// Scan order (alphabetical by id) comes from the loader and is preserved:
|
||||
// there is no dependency resolution, so any other order would imply a
|
||||
// precedence nothing computes (MODULE_API.md §4.2).
|
||||
return res.json({ modules: loader.list().filter((m) => m.state === 'started').map(publish) })
|
||||
} catch (err) {
|
||||
// The only reachable throw is §7.6's guard — the module list read before
|
||||
// modules.load() ran. That is a mis-ordered boot, not a bad request, so it
|
||||
// is logged rather than answered with an empty list: `{ modules: [] }` is a
|
||||
// true answer for a core with no modules installed and a caller cannot tell
|
||||
// the two apart.
|
||||
log.error('module list unavailable', { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getModules }
|
||||
@@ -1,30 +0,0 @@
|
||||
// Public · Modules — the installed-module list a client feature-detects against.
|
||||
//
|
||||
// Mounted at /api/v1/public/modules by public/index.js. One route, and it owns a
|
||||
// prefix rather than sitting beside /settings and /version in site.router.js —
|
||||
// which is the point of the file existing at all. The loader asks the LIVE
|
||||
// public tier router whether a prefix is already core's (`ownedByCore`,
|
||||
// modules/loader.js), and it skips root-mounted layers because a `use('/', …)`
|
||||
// matches every path. A route declared inside the root-mounted site router is
|
||||
// therefore invisible to that probe; a real `use('/modules', …)` layer is not.
|
||||
// So mounting it here is what makes "no module may ever claim /modules" an
|
||||
// enforced rule instead of a convention.
|
||||
//
|
||||
// No siteMode gate and no database — see modules.controller.js for why.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./modules.controller')
|
||||
|
||||
const modulesRouter = express.Router()
|
||||
|
||||
modulesRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Installed modules (id, version, capabilities)'
|
||||
// #swagger.description = 'The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.'
|
||||
/* #swagger.responses[200] = { description: 'The started modules', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicModules" } } } } */
|
||||
ctrl.getModules,
|
||||
)
|
||||
|
||||
module.exports = modulesRouter
|
||||
@@ -14,10 +14,9 @@ const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
const modules = require('./modules/loader')
|
||||
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
|
||||
const shardMarket = require('./model/shardMarket/shardMarket.model')
|
||||
const moduleLifecycle = require('./modules/lifecycle')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||
const brand = require('./config/brand')
|
||||
@@ -81,16 +80,15 @@ async function start() {
|
||||
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
||||
}
|
||||
|
||||
// Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
|
||||
// change over its lifetime — facets get added, replaced or renamed — so the
|
||||
// atlas is rebuilt on every boot rather than shipped as a snapshot that would
|
||||
// silently go stale. Hash-gated, so an unchanged tree costs one read pass and
|
||||
// no database write.
|
||||
// Installed modules' onBoot hooks. After ensureSchema() and seedDefaults(), so
|
||||
// a module's own tables exist; before the listener binds, so a module that must
|
||||
// warm a cache before serving gets that for free. Each hook is individually
|
||||
// try/caught inside the loader — a module that throws here loses its `started`
|
||||
// state and its routes answer 503, and the site still comes up.
|
||||
//
|
||||
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||
// malformed file must never stop the site coming up. A refresh that would
|
||||
// REMOVE a facet is staged for admin approval instead of being applied.
|
||||
await shardAtlas.refreshOnBoot()
|
||||
// The spawn atlas's boot refresh used to be an explicit call here; it is now
|
||||
// module-uo's onBoot (docs/website/MODULE_API.md §2.5).
|
||||
await modules.boot()
|
||||
|
||||
// Refresh the cliloc table (UO's id → display-string map) from the file the
|
||||
// operator converted out of their own client. Same contract as the atlas:
|
||||
@@ -110,15 +108,6 @@ 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)`)
|
||||
@@ -184,16 +173,11 @@ 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,35 +2,59 @@
|
||||
//
|
||||
// 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 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.
|
||||
// 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.
|
||||
|
||||
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 registries = require('../modules/registries')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const botInternalClient = require('./botInternalClient')
|
||||
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
|
||||
@@ -39,9 +63,16 @@ async function processLeg(job, leg) {
|
||||
return
|
||||
}
|
||||
|
||||
let result
|
||||
let classification
|
||||
try {
|
||||
classification = registered.classify(await registered.dispatch(post))
|
||||
if (leg === 'towncrier') {
|
||||
result = await dispatchTownCrier(post)
|
||||
classification = logic.classifyTownCrier(result)
|
||||
} else {
|
||||
result = await dispatchDiscord(post)
|
||||
classification = logic.classifyDiscord(result)
|
||||
}
|
||||
} catch (err) {
|
||||
// Clients shouldn't throw, but if one does, treat it as a transient failure
|
||||
// rather than crashing the tick.
|
||||
@@ -52,10 +83,11 @@ 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 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).
|
||||
// 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).
|
||||
async function tick(now = new Date()) {
|
||||
let jobs
|
||||
try {
|
||||
@@ -67,15 +99,15 @@ async function tick(now = new Date()) {
|
||||
if (!jobs || jobs.length === 0) return
|
||||
|
||||
for (const job of jobs) {
|
||||
for (const row of job.legs || []) {
|
||||
if (isLegDue(row, now)) await processLeg(job, row.leg)
|
||||
}
|
||||
if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier')
|
||||
if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord')
|
||||
}
|
||||
}
|
||||
|
||||
function isLegDue(row, now) {
|
||||
if (!row || row.status !== 'pending') return false
|
||||
return row.next_attempt_at == null || new Date(row.next_attempt_at) <= now
|
||||
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
|
||||
}
|
||||
|
||||
let timer = null
|
||||
@@ -86,7 +118,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, legs: registries.announceLegIds() })
|
||||
log.info('announcement dispatcher started', { pollMs: POLL_MS })
|
||||
return timer
|
||||
}
|
||||
|
||||
@@ -97,4 +129,4 @@ function stop() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, processLeg, isLegDue }
|
||||
module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord }
|
||||
|
||||
@@ -4,7 +4,6 @@ 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',
|
||||
@@ -42,37 +41,67 @@ async function query(sql, params) {
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
|
||||
|
||||
/**
|
||||
* Split a schema file into executable statements.
|
||||
*
|
||||
* Strips `--` comments (full-line AND trailing) before splitting — so a leading
|
||||
* comment block doesn't get glued onto the statement that follows it, and a `;`
|
||||
* inside a trailing comment can't chop a statement in half. Safe because the
|
||||
* schema never puts `--` inside a string literal, which is a rule module
|
||||
* fragments inherit (docs/website/MODULE_API.md §2.6).
|
||||
*/
|
||||
function statementsOf(sql) {
|
||||
return sql
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const i = line.indexOf('--')
|
||||
return i === -1 ? line : line.slice(0, i)
|
||||
})
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tables if they do not exist. Idempotent. Retries while the DB is still
|
||||
* coming up (important under docker-compose even with a healthcheck).
|
||||
*
|
||||
* 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.
|
||||
* Installed modules' schema fragments are replayed immediately after core's, by
|
||||
* this same function — there is no migration runner here to model a module one
|
||||
* on, and inventing one for modules alone would leave core and modules on two
|
||||
* different schema models (MODULE_SYSTEM.md §1.6).
|
||||
*/
|
||||
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
|
||||
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 {
|
||||
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
|
||||
for (const statement of splitStatements(sql)) {
|
||||
for (const statement of statementsOf(fs.readFileSync(SCHEMA_PATH, 'utf8'))) {
|
||||
await conn.query(statement)
|
||||
}
|
||||
log.info('schema ensured')
|
||||
|
||||
// Module fragments, after core's. Required lazily: the loader requires
|
||||
// this file for ctx.db, and a top-level require would be a cycle.
|
||||
// eslint-disable-next-line global-require
|
||||
const modules = require('../modules/loader')
|
||||
for (const fragment of modules.schemaFragments()) {
|
||||
// Per fragment, not per statement: a module whose schema is broken
|
||||
// must lose its own tables and nothing else, and must not abort the
|
||||
// retry loop and take the site's boot with it.
|
||||
try {
|
||||
for (const statement of statementsOf(fragment.sql)) {
|
||||
await conn.query(statement)
|
||||
}
|
||||
log.info(`schema ensured for module "${fragment.id}"`)
|
||||
} catch (err) {
|
||||
modules.markFailed(fragment.id, `schema fragment: ${err.message}`)
|
||||
log.error(`module "${fragment.id}" schema fragment failed — its routes will answer 503`, {
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
conn.release()
|
||||
@@ -87,15 +116,7 @@ async function ensureCoreSchema({ retries, delayMs }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// ── 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 }
|
||||
@@ -83,7 +83,7 @@ function absolutize(url) {
|
||||
* @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 the same-origin URLs of installed modules' client chunks.
|
||||
* `moduleEntries` are same-origin URLs of installed modules' prebuilt chunks.
|
||||
* @returns {string}
|
||||
*/
|
||||
function render(html, overrides = {}) {
|
||||
@@ -103,46 +103,14 @@ 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 ')
|
||||
const scripts = moduleScriptTags(overrides.moduleEntries)
|
||||
const withHead = html
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
if (scripts.length === 0) return withHead
|
||||
return withHead.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n </body>`)
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// **Injected before `</body>`, not into `</head>`, and the position is the
|
||||
// contract.** Module scripts are deferred, so they execute in document order
|
||||
// after core's own bundle — which is where `window.__rg` is published, and what
|
||||
// every one of a module's imports resolves against. Vite happens to hoist core's
|
||||
// entry script into `<head>` today, which would make a `</head>` injection work
|
||||
// too; that is a bundler's emit choice, and if it ever changed, every module in
|
||||
// the wild would break on its first import with nothing in this repo having been
|
||||
// edited. Last in the body is after core's script wherever core's script is.
|
||||
//
|
||||
// The path is built by the loader from the module id and the entry's basename,
|
||||
// both already validated, so nothing operator-supplied reaches the attribute.
|
||||
// It is re-checked here anyway: what may appear in an HTML attribute should be a
|
||||
// property of the code that writes the HTML, not of a validator two files away
|
||||
// staying strict.
|
||||
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9][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>`)
|
||||
}
|
||||
|
||||
// The admin theme as a :root block, or '' when this instance has never been
|
||||
@@ -157,6 +125,27 @@ function themeStyleTag(theme) {
|
||||
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
|
||||
}
|
||||
|
||||
// Installed modules' prebuilt client chunks (docs/website/MODULE_API.md §3.1).
|
||||
//
|
||||
// `type="module"` with a `src`, never inline: `script-src 'self'` admits a
|
||||
// same-origin src with no nonce, and an inline tag would be blocked outright —
|
||||
// which is also why the shared dependencies ride on window.__rg rather than an
|
||||
// import map, since an import map has to be inline.
|
||||
//
|
||||
// `defer` is implicit for a module script, so these evaluate after the SPA's own
|
||||
// bundle has published window.__rg and before it renders. The path is built by
|
||||
// the loader from the module id, so nothing user-supplied reaches the attribute;
|
||||
// it is escaped anyway, because a rule about what CAN appear here should not
|
||||
// depend on a validator three modules away staying strict.
|
||||
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9._-]+\.js$/
|
||||
|
||||
function moduleScriptTags(entries) {
|
||||
if (!Array.isArray(entries)) return []
|
||||
return entries
|
||||
.filter((src) => typeof src === 'string' && MODULE_ENTRY_PATH.test(src))
|
||||
.map((src) => `<script type="module" src="${htmlEscape(src)}"></script>`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the built index.html. Called once at boot by app.js; a separate step
|
||||
* from get() so the file read stays synchronous and startup still fails loudly
|
||||
@@ -204,25 +193,17 @@ async function get() {
|
||||
// mean a failing query per page view.
|
||||
overrides = {}
|
||||
}
|
||||
// The module list is in-memory and filesystem-derived, so unlike the brand
|
||||
// read above it cannot fail on a DB fault and needs no fallback of its own.
|
||||
// Required lazily for the same reason the settings model is: app.js requires
|
||||
// this file, and the loader would otherwise be pulled into that chain.
|
||||
let moduleEntries = []
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
moduleEntries = require('../modules/loader').clientEntryUrls()
|
||||
} catch {
|
||||
// The only reachable throw is §7.6's guard — the shell rendered before
|
||||
// modules.load() ran, which app.js's ordering makes impossible and a test
|
||||
// that renders in isolation makes possible. A page with no module scripts
|
||||
// is the right answer either way; it is what a bare core serves.
|
||||
moduleEntries = []
|
||||
}
|
||||
// Note for whoever builds the admin Modules screen: a state change after boot
|
||||
// (an operator disabling a module) has to call invalidate(), exactly as a
|
||||
// brand-asset write does. The TTL converges on its own within five minutes;
|
||||
// the explicit call is what makes the toggle feel like it did something.
|
||||
// The module list is filesystem-derived and synchronous, so unlike the brand
|
||||
// read above it cannot fail on a DB fault and needs no fallback. Only STARTED
|
||||
// modules get a script tag: a module whose onBoot failed answers 503 on its
|
||||
// API, and loading its client half would render pages against a dead backend.
|
||||
// eslint-disable-next-line global-require
|
||||
const modules = require('../modules/loader')
|
||||
const moduleEntries = modules
|
||||
.list()
|
||||
.filter((m) => m.state === 'started' && m.entryUrl)
|
||||
.map((m) => m.entryUrl)
|
||||
|
||||
const html = render(template, { ...overrides, moduleEntries })
|
||||
// An invalidation that landed while this read was in flight means the value
|
||||
// we just read may already be stale. Serve it, but do not cache it.
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// ── Push-notification fan-out (content-free tickles) ───────────────────────
|
||||
//
|
||||
// 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`.
|
||||
// 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', …).
|
||||
//
|
||||
// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
|
||||
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy
|
||||
@@ -22,7 +18,9 @@
|
||||
// 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
|
||||
@@ -108,4 +106,30 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
module.exports = { publish, isAllowedEndpoint }
|
||||
// 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 }
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// ── 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 shardPush = require('./shardPush')
|
||||
const pushDispatch = require('./pushDispatch')
|
||||
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 || shardPush.fromShardEvent,
|
||||
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// ── 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 }
|
||||
@@ -1,37 +0,0 @@
|
||||
// ── 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 }
|
||||
@@ -1,115 +0,0 @@
|
||||
// ── 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 }
|
||||
@@ -1,120 +0,0 @@
|
||||
// ── 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",
|
||||
"summary": "Retry one announcement delivery leg (town crier or Discord)",
|
||||
"description": "",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -3308,7 +3308,10 @@
|
||||
"properties": {
|
||||
"leg": {
|
||||
"type": "string",
|
||||
"description": "A registered delivery leg id, as returned by GET /announce."
|
||||
"enum": [
|
||||
"towncrier",
|
||||
"discord"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -11198,367 +11201,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/public/atlas/champions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Atlas"
|
||||
],
|
||||
"summary": "Configured champion altars (the roster, not the live board)",
|
||||
"description": "Where the altars are and what each one summons — \"there is an Unholy Terror altar in Deceit\". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board (\"it is on level 3 right now\").",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "facet",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Limit to one facet."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Altars, by facet then name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AtlasChampion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/atlas/creatures": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Atlas"
|
||||
],
|
||||
"summary": "Search the bestiary (paginated)",
|
||||
"description": "Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\\'s share on it. Static content parsed from the shard\\'s ServUO tree — unaffected by the shard being offline.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Substring match on the creature name (max 60 chars)."
|
||||
},
|
||||
{
|
||||
"name": "facet",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Limit to creatures spawning on this facet. Facet names come from the shard's own files; an unknown one returns an empty page."
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Page size, 1..100 (default 50)."
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Rows to skip (default 0)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A page of creatures plus the unpaginated total",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AtlasCreaturePage"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "The atlas feature is gated above this caller",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "The atlas feature is disabled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/atlas/creatures/{slug}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Atlas"
|
||||
],
|
||||
"summary": "One creature: where it spawns, and what spawns with it",
|
||||
"description": "The answer the atlas exists to give. `places` is the aggregate — \"lizardman → Shrines, Isamu-Jima, Yew\" — resolved by point-in-rect against the shard\\'s own region rectangles, falling back to the nearest landmark, else \"Wilderness\". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Creature slug, e.g. lizardman."
|
||||
},
|
||||
{
|
||||
"name": "facet",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Restrict places and spawners to one facet."
|
||||
},
|
||||
{
|
||||
"name": "points",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Max spawners to return, 1..1000 (default 200)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The creature",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AtlasCreature"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such creature in this atlas (or the feature is disabled)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/atlas/landmarks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Atlas"
|
||||
],
|
||||
"summary": "Points of interest (dungeon levels, town markers)",
|
||||
"description": "From the shard\\'s Data/Locations files. `group` is the innermost enclosing parent (\"Covetous\"), which is the label worth showing over the individual marker (\"Level 1\").",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "facet",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Limit to one facet."
|
||||
},
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Substring match on the landmark name or its group."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Landmarks, by facet then group",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AtlasLandmark"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/atlas/meta": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Atlas"
|
||||
],
|
||||
"summary": "What atlas is loaded: facets, counts, when it was imported",
|
||||
"description": "Drives the facet filter and the \"parsed from the shard\\'s own files on <date>\" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Atlas metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AtlasMeta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/atlas/regions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Atlas"
|
||||
],
|
||||
"summary": "Named regions and their rectangles",
|
||||
"description": "Flattened out of the shard\\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "facet",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Limit to one facet."
|
||||
},
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Substring match on the region name."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Regions, by facet then name",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AtlasRegion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/contact": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -11620,30 +11262,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/modules": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public"
|
||||
],
|
||||
"summary": "Installed modules (id, version, capabilities)",
|
||||
"description": "The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The started modules",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublicModules"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/pages/{id}/preview/{token}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -17811,131 +17429,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicModules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PublicModule"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicModule": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One started module, as published to anonymous clients."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "uo"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Module id — also the URL segment its routes live under (/api/v1/public/<id-owned prefixes>)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Ultima Online"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Human label."
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "1.0.0"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The module's own version (semver). Unrelated to the API version."
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "shard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Brand": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -737,31 +737,6 @@ const doc = {
|
||||
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
||||
},
|
||||
},
|
||||
PublicModules: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated.',
|
||||
properties: {
|
||||
modules: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/PublicModule' },
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicModule: {
|
||||
type: 'object',
|
||||
description: 'One started module, as published to anonymous clients.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'uo', description: 'Module id — also the URL segment its routes live under (/api/v1/public/<id-owned prefixes>).' },
|
||||
name: { type: 'string', example: 'Ultima Online', description: 'Human label.' },
|
||||
version: { type: 'string', example: '1.0.0', description: 'The module\'s own version (semver). Unrelated to the API version.' },
|
||||
capabilities: {
|
||||
type: 'array',
|
||||
description: 'Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent.',
|
||||
items: { type: 'string', example: 'shard' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Brand: {
|
||||
type: 'object',
|
||||
description:
|
||||
@@ -1538,31 +1513,9 @@ function normalizePaths(spec) {
|
||||
return spec
|
||||
}
|
||||
|
||||
// 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 */
|
||||
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.')
|
||||
})
|
||||
|
||||
@@ -14,14 +14,7 @@ async function startApp(configure) {
|
||||
const { port } = server.address()
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
// `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)
|
||||
}),
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// ── 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()
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user