3 Commits

Author SHA1 Message Date
91964c8898 Merge pull request 'feat(modules): client extension slots (phase 3, slice 2)' (#138) from feature/module-client-slots into edge
Reviewed-on: #138
2026-08-11 21:53:41 +00:00
d667565ae7 refactor(modules): move core's UO page content behind the two slots
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / bot-install (pull_request) Successful in 8m46s
Core declares site.footer.status and admin.users.detail in main.jsx and fills
both itself, under owner id `core` -- the client twin of registries.registerCore()
and the same trick useShardFlags already uses. The rendered page is unchanged;
what changes is that the content now arrives the way a module's will.

The footer's Shard Status link becomes ShardStatusLink.jsx, and UserDetail's six
UO sections become UserShardSections.jsx. Both are files rather than inline
markup so that the client half of phase 3 deletes a registration and a file
instead of editing a core page under extraction pressure -- which is also what
proves the mechanism before anything depends on it.

The user-detail slot is handed userId and not scope. api.admin.userShard is a UO
binding that leaves core with the client half, so a slot passing it would hand a
module something core is about to delete; an extension builds its own client for
the routes it registered at the other end. Core's own fill now does exactly what
the module will.

Verified in a browser against a real chunk (MODULE_API.md 7.7): a throwaway
module fills both slots and renders its own label and target in the footer with
core's linkStyle, and receives userId on the admin page; a deliberate render
failure is contained to that one spot with the slot named in the console; core's
own fills leave the pages byte-identical to before; and with no module installed
both slots render nothing. Zero CSP reports throughout.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:45:22 -05:00
1d1350558b feat(modules): client extension slots (phase 3, slice 2)
The client twin of the server's declareSlot/registerExtension, and the same rule
in both halves: core declares a slot, only core declares one, and at most one
module fills it. Core renders <Slot name> and gets nothing back when the slot is
unfilled, so an instance with no module installed renders exactly what it
rendered before -- the same untouched-path guarantee withModuleNav makes.

A slot is named for a PLACE, never for a meaning. Core supplies the position and
the styling; the label, the target, the data and whether anything renders at all
are the module's. The moment core types a slot by its content it has re-acquired
the game semantics phase 3 exists to remove.

This is the one place the client registry is not fail-open. An unknown slot, a
non-component and a second fill all throw, matching checkExtensionShape
server-side, because a dropped nav row costs a link the viewer can reach another
way while a silently dropped extension is invisible to everyone including its
author. A throw is always a programming error and never a race: core declares in
its own bundle and every module chunk is a deferred script injected after it.

Reading stays fail-safe -- undeclared and unfilled both read null -- and a
filling component renders inside an error boundary. That asymmetry is where the
client differs from the server: a module route that throws costs the module's own
page, but an extension throws inside CORE's, and the whole reason core keeps
ownership of that page is that it stays usable.

Core decorates a slot through <Slot wrap>, not by asking whether it is filled.
The obvious alternative is right about the unfilled case and wrong about the
failed one -- the extension is filled, so the separator renders, and then the
component throws into the boundary and leaves the separator behind on its own.
wrap puts core's decoration inside the boundary where it shares the extension's
fate. Found in a browser, with the footer's separator, which is the only place
either could have been found.

MODULE_API_VERSION 1.1.0 -> 1.2.0, both halves: the two state ONE version.
Contract: docs/website/MODULE_API.md 3.7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:45:08 -05:00
11 changed files with 495 additions and 142 deletions

View File

@@ -0,0 +1,24 @@
// ── Core's fill for the `site.footer.status` extension slot ────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
// MODULE_API.md §3.7.
//
// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
// it is a file now for one reason: `/site/shard` is a UO page, so the link goes
// when the client half goes, and core should be deleting a registration rather
// than editing its footer under extraction pressure.
//
// Note what core kept and what it handed over. Core owns the position in the row
// and the separator around it, and passes `linkStyle` so the row stays visually
// one row. The label, the destination, and the decision to render at all are
// this file's — which is exactly the division a module inherits.
import { Link } from 'react-router-dom'
export default function ShardStatusLink({ linkStyle }) {
return (
<Link to="/site/shard" style={linkStyle}>
Shard Status
</Link>
)
}

View File

@@ -1,5 +1,14 @@
import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx'
import Slot from '../modules/Slot.jsx'
const FOOTER_SLOT = 'site.footer.status'
// Handed to the extension rather than left for it to guess. A module rendering
// its own link in this row should look like the row, and the alternative is
// every module restating core's colours and then drifting from them the next
// time this footer is themed.
const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' }
export default function SiteFooter() {
const { contactEmail, siteTitle } = useSite()
@@ -36,10 +45,14 @@ export default function SiteFooter() {
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail}
</a>
&nbsp;·&nbsp;
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Shard Status
</Link>
{/* A module's spot in the footer, and core supplies only the
position and the styling: the label, the target and whether
anything renders at all are the module's (MODULE_API.md §3.7).
The separator goes through `wrap` rather than sitting beside the
slot, so it shares the extension's fate — no module installed and
a module whose link throws both render nothing here, rather than
the second leaving a stray middot behind. */}
<Slot name={FOOTER_SLOT} linkStyle={LINK_STYLE} wrap={(link) => <>&nbsp;·&nbsp;{link}</>} />
&nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin

View File

@@ -3,8 +3,10 @@ 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 { declareSlot, registerExtension, registerFeatureProvider } from './modules/registry.js'
import { useShardFlags } from './lib/useShardFeatures.js'
import ShardStatusLink from './components/ShardStatusLink.jsx'
import UserShardSections from './routes/admin/views/UserShardSections.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -28,6 +30,34 @@ publishSharedDependencies()
// for them by the name they will always have had.
registerFeatureProvider('core', 'uo', useShardFlags)
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
// Declared HERE, in core's own bundle, which is what makes the ordering a fact
// rather than a hope: module chunks are deferred scripts the shell injects after
// this one (§3.1), so a module can never reach registerExtension before the slot
// it names exists. "Unknown slot" therefore always means a typo or a version
// skew, never a load-order accident — which is why that case throws.
//
// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is
// the spot in the footer's info row, not a declaration that core knows what a
// game server's status is; the label, the target and whether anything renders at
// all belong to whoever fills it. A slot typed by its content would put game
// semantics back into core, which is the thing Phase 3 takes out.
declareSlot('site.footer.status')
// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one
// resource, one extension point, two halves. The module with routes under
// /api/v1/admin/users/:id is the module with something to show on that page.
declareSlot('admin.users.detail')
// And core fills both itself, under owner id `core`, with the components that
// were inline in SiteFooter.jsx and UserDetail.jsx until this slice. The page
// renders exactly what it rendered before, and the mechanism is exercised by
// core's own content from the day it lands rather than first proved by the
// change that depends on it. Slice 3 deletes these three lines and the two files
// they name, and the module registers the same two slots on its way in.
registerExtension('core', 'site.footer.status', ShardStatusLink)
registerExtension('core', 'admin.users.detail', UserShardSections)
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//

View File

@@ -0,0 +1,68 @@
// ── <Slot> — where core renders a module's content ─────────────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the normative
// contract is docs/website/MODULE_API.md §3.7.
//
// The read side of registry.js's extension slots. Core puts one of these where a
// module may contribute to a core page, and gets back either the filling
// component with the props core passed, or nothing at all.
//
// **Nothing at all is the important half.** An instance with no module installed
// renders the identical page it renders today, which is the same untouched-path
// guarantee `withModuleNav` makes for nav — and the reason a core layout can
// place a slot without also acquiring an empty-state to design.
import React from 'react'
import { extensionFor } from './registry.js'
/**
* Contain a module's render failure to the module's own section.
*
* This is where the client differs from the server, deliberately. A module
* *route* that throws costs the module's own page and core does not need to care.
* An extension throws inside CORE's page — the admin's user detail, the site
* footer — and the whole reason core keeps ownership of that page is that it
* stays usable. So a slot renders nothing and logs, rather than taking the
* surrounding page down with it.
*
* A class because that is what React gives us: there is no hook form of
* componentDidCatch, and this is the only error boundary core has.
*/
class SlotBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { failed: false }
}
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error) {
// Named so the console says whose fault it is: a blank section with an
// anonymous stack is how a module bug becomes core's support ticket.
console.error(`[modules] extension in slot "${this.props.name}" threw and was dropped`, error)
}
render() {
return this.state.failed ? null : this.props.children
}
}
/**
* @param {string} name the slot id, declared by core in main.jsx
* @param {function} [wrap] core markup that only makes sense AROUND a rendered
* extension — a separator, a heading, a rule. Called with the extension's
* element and rendered inside the boundary, so it shares the extension's fate:
* an unfilled slot and a failed one both render nothing at all, decoration
* included. Found in a browser, because the obvious alternative — asking
* whether the slot is filled and rendering the separator alongside — is right
* about the unfilled case and leaves a stray separator behind on the failed one.
* @param {object} props everything else is handed to the filling component
*/
export default function Slot({ name, wrap, ...props }) {
const Extension = extensionFor(name)
if (!Extension) return null
const element = <Extension {...props} />
return <SlotBoundary name={name}>{wrap ? wrap(element) : element}</SlotBoundary>
}

View File

@@ -32,6 +32,8 @@
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
// slot name → { Component, filledBy }.
const slots = new Map()
const registered = new Set()
const AREAS = ['public', 'admin', 'player']
@@ -100,6 +102,70 @@ export function registerFeatureProvider(id, namespace, hook) {
registered.add(id)
}
// ── Extension slots (§3.7) ─────────────────────────────────────────────────
//
// The client twin of the server's declareSlot/registerExtension, and the same
// rule in both halves: core declares a slot, ONLY core declares one, and at most
// one module fills it. Core renders `<Slot name>` (Slot.jsx) and gets nothing
// back when the slot is unfilled — so an instance with no module installed
// renders exactly what it renders today.
//
// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a
// position in the footer and the styling that goes with it; the label, the
// target, the data and whether anything renders at all are the module's. The
// moment core types a slot by its content it has re-acquired the game semantics
// this whole extraction removes.
/**
* @param {string} name the slot id. Core-only — deliberately not on the
* `registry` object handed to modules.
*/
export function declareSlot(name) {
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
slots.set(name, { Component: null, filledBy: null })
}
/**
* Fill a declared slot with a component.
*
* **This is the one place the client registry is not fail-open**, and the
* asymmetry is deliberate. A dropped nav row costs a link the viewer can reach
* another way; a silently dropped extension is invisible to everyone including
* its author. So an unknown slot, a non-component, and a second fill all throw —
* exactly as the server's checkExtensionShape does.
*
* A throw here is always a programming error and never a race, because
* declaration structurally precedes filling: core declares in main.jsx, inside
* its own bundle, and every module chunk is a deferred script injected after it
* (§3.1).
*/
export function registerExtension(id, slot, Component) {
const entry = slots.get(slot)
if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`)
if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`)
if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`)
entry.Component = Component
entry.filledBy = id
registered.add(id)
}
/**
* The filling component, or null.
*
* Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension`
* for a core layout to branch on, because a layout that asks whether a slot is
* filled and then renders its own decoration alongside gets the *failed* case
* wrong: the extension is filled, so the decoration renders, and the component
* then throws into the boundary leaving the decoration behind on its own. Core
* decorates through `<Slot wrap>` instead, which puts the decoration inside the
* boundary where it shares the extension's fate. (Found in a browser, with the
* footer's separator.)
*
* Undeclared and unfilled both read null: reading is fail-safe, and only writing
* is strict.
*/
export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null
export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
@@ -131,6 +197,11 @@ export function _reset() {
nav[area].length = 0
}
providers.clear()
// Declarations go too, unlike the server's, where a slot is declared once at
// require time by the router that owns it. Core declares its slots in
// main.jsx — the one file no test loads — so on this side there is nothing
// declared at import time for a surviving declaration to protect.
slots.clear()
registered.clear()
}
@@ -142,6 +213,7 @@ export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
registerExtension,
routesFor,
navFor,
featureProviderFor,

View File

@@ -11,9 +11,13 @@
// 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.
// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots
// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an
// addition: a module that never fills a slot is unaffected. The server half is
// untouched and bumps anyway, for the reason below.
// 1.1.0 — the server's ctx gained activity.log, users.getById, site.baseUrl and
// the rate-limit factory (MODULE_API.md §2.3). Nothing on window.__rg changed,
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.1.0'
export const MODULE_API_VERSION = '1.2.0'

View File

@@ -1,17 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime, ago } from '../../../lib/format.js'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
import Slot from '../../../modules/Slot.jsx'
// Admin read-only view of one user's shard (uo-link) footprint: linked game
// accounts + character rosters, currently-online characters, houses (incl.
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
// Reached from the Users table's "View" action; Edit stays a separate modal.
// Admin view of one user: who they are, their security posture (trusted devices
// and MFA), and then whatever the installed module contributes about them —
// today core's own UO footprint, via the `admin.users.detail` extension slot
// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
// a separate modal.
const ROLE_BADGE = {
admin: 'badge-admin',
@@ -28,114 +27,6 @@ function SectionTitle({ children }) {
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
@@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) {
)
}
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
export default function UserDetail() {
const { id } = useParams()
// Memoize so the child components' effects (keyed on `scope`) don't refetch
// on every render.
const scope = useMemo(() => api.admin.userShard(id), [id])
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading />
@@ -296,7 +170,10 @@ export default function UserDetail() {
</div>
<SecurityAdmin userId={id} />
<ShardSections scope={scope} />
{/* Whatever the installed module has to say about this user, or nothing
at all. Core's own UO sections fill it today (UserShardSections.jsx,
registered in main.jsx) — MODULE_API.md §3.7. */}
<Slot name="admin.users.detail" userId={id} />
</section>
)
}

View File

@@ -0,0 +1,163 @@
// ── Core's fill for the `admin.users.detail` extension slot ────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
// is UO, and every one of them leaves core with the client half in slice 3 —
// this file exists so that when they do, core deletes a registration and a file
// instead of unpicking a page.
//
// Core registers it through the same seam a module uses
// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
// server's `registries.registerCore()` and the same trick `useShardFlags`
// already uses for the feature seam. The mechanism is therefore exercised by
// core's own content from the day it lands, rather than first proved by the
// change that depends on it.
//
// The slot hands over `userId` and nothing else — deliberately, not `scope`.
// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
// that passed it would be handing a module something core is about to delete.
// An extension builds its own client for the routes it registered at the other
// end (MODULE_API.md §3.5), and this file does exactly what the module will.
import { useMemo } from 'react'
import { useAsync } from '../../../lib/useAsync.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Its own copy, not an export from UserDetail.jsx: six lines of presentational
// furniture that is not in the §3.4 kit, so a module filling this slot would
// vendor the same thing. Core's copy stays behind with core's own security
// panel, which is the other caller.
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
export default function UserShardSections({ userId }) {
// Memoized so the child components' effects (keyed on `scope`) don't refetch
// on every render — the same reason UserDetail memoized it before this moved.
const scope = useMemo(() => api.admin.userShard(userId), [userId])
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}

View File

@@ -162,6 +162,7 @@ test('the registry object handed to modules exposes the whole surface', () => {
assert.deepEqual(Object.keys(registry).sort(), [
'featureProviderFor',
'navFor',
'registerExtension',
'registerFeatureProvider',
'registerNav',
'registerRoutes',

View File

@@ -0,0 +1,94 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import {
registry,
declareSlot,
registerExtension,
extensionFor,
registeredIds,
_reset,
} from '../src/modules/registry.js'
// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of
// the server's declareSlot/registerExtension.
//
// The registry half only. `<Slot>` itself renders, and there is no DOM in this
// runner, so what it does with what these functions return — including the error
// boundary — is proved by the §7.7 browser smoke instead. Everything below is a
// rule that can be stated without rendering anything, and every one of them can
// be got wrong in a way a browser check would not obviously catch.
beforeEach(() => _reset())
const Fake = () => null
const Other = () => null
test('an unfilled slot reads as nothing', () => {
// The guarantee core's layouts rest on: place a slot, install no module, and
// the page renders what it rendered before.
declareSlot('site.footer.status')
assert.equal(extensionFor('site.footer.status'), null)
})
test('an undeclared slot reads as nothing rather than throwing', () => {
// Reading is core's side and stays fail-safe: a typo in a layout costs that
// spot, not the page. Only WRITING is strict, which is the next test.
assert.equal(extensionFor('nope'), null)
})
test('a module fills a declared slot and core reads it back', () => {
declareSlot('admin.users.detail')
registerExtension('uo', 'admin.users.detail', Fake)
assert.equal(extensionFor('admin.users.detail'), Fake)
assert.deepEqual(registeredIds(), ['uo'])
})
test('filling an unknown slot throws, naming the slot', () => {
// This is the one place the client registry is NOT fail-open, and the reason
// is asymmetry of consequence: a dropped nav row costs a link the viewer can
// reach another way, a silently dropped extension is invisible to everyone
// including its author. Declaration structurally precedes filling (§3.1), so
// this can only ever be a typo or a version skew.
assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/)
})
test('a non-component fill throws', () => {
declareSlot('site.footer.status')
assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/)
})
test('a second module cannot take a filled slot, and the first keeps it', () => {
// Matches the server's rule exactly (registries.js): first fill wins, second
// is an error. The second half of the assertion is the one that matters — a
// rejected fill must not have half-replaced the incumbent.
declareSlot('admin.users.detail')
registerExtension('uo', 'admin.users.detail', Fake)
assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/)
assert.equal(extensionFor('admin.users.detail'), Fake)
})
test('declaring a slot twice throws', () => {
// Core-side programming error: two owners for one position means whichever
// module registered first wins by file order.
declareSlot('site.footer.status')
assert.throws(() => declareSlot('site.footer.status'), /already declared/)
})
test('core fills a slot through the same seam a module uses', () => {
// The client twin of registries.registerCore(). Core is a registrant with an
// id like any other, which is what makes slice 3 a deletion: the module
// registers the same slot and core drops its line.
declareSlot('site.footer.status')
registerExtension('core', 'site.footer.status', Fake)
assert.deepEqual(registeredIds(), ['core'])
})
test('declareSlot and extensionFor are not on the module-facing registry', () => {
// Declaring is core's alone (§3.7), and reading who filled a slot is core's
// too — the same line featureProviders() draws. registerExtension IS on the
// object, because filling is the whole point.
assert.equal(registry.declareSlot, undefined)
assert.equal(registry.extensionFor, undefined)
assert.equal(typeof registry.registerExtension, 'function')
})

View File

@@ -9,11 +9,18 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.2.0 — the CLIENT registry gained `registerExtension` and core gained client
// extension slots (MODULE_API.md §3.7): the twin of this half's declareSlot /
// registerExtension, for module content inside a core *page* rather than under a
// core route prefix. Nothing on the server changed, and this file bumps anyway —
// the two halves state ONE version, because a module declares a single `coreApi`
// range and is served one chunk (client/src/modules/version.js).
//
// 1.1.0 — `ctx` gained `activity.log`, `users.getById` and `site.baseUrl`, each
// because module-uo's extraction needed it and none of them could be vendored:
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.1.0'
const MODULE_API_VERSION = '1.2.0'
module.exports = { MODULE_API_VERSION }