diff --git a/client/src/components/ShardStatusLink.jsx b/client/src/components/ShardStatusLink.jsx
new file mode 100644
index 0000000..ffaea4c
--- /dev/null
+++ b/client/src/components/ShardStatusLink.jsx
@@ -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 (
+
+ Shard Status
+
+ )
+}
diff --git a/client/src/components/SiteFooter.jsx b/client/src/components/SiteFooter.jsx
index 5b94977..66f65b0 100644
--- a/client/src/components/SiteFooter.jsx
+++ b/client/src/components/SiteFooter.jsx
@@ -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() {
{contactEmail}
- ·
-
- Shard Status
-
+ {/* 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. */}
+ <> · {link}>} />
·
Admin
diff --git a/client/src/main.jsx b/client/src/main.jsx
index de0fa13..f1c81fa 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -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.
//
diff --git a/client/src/modules/Slot.jsx b/client/src/modules/Slot.jsx
new file mode 100644
index 0000000..1ae2d92
--- /dev/null
+++ b/client/src/modules/Slot.jsx
@@ -0,0 +1,68 @@
+// ── — 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 =
+ return {wrap ? wrap(element) : element}
+}
diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js
index bdc7892..87d7a03 100644
--- a/client/src/modules/registry.js
+++ b/client/src/modules/registry.js
@@ -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.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 `` 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,
diff --git a/client/src/modules/version.js b/client/src/modules/version.js
index b527fe5..466b4c9 100644
--- a/client/src/modules/version.js
+++ b/client/src/modules/version.js
@@ -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'
diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx
index cd552cf..93f9cca 100644
--- a/client/src/routes/admin/views/UserDetail.jsx
+++ b/client/src/routes/admin/views/UserDetail.jsx
@@ -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 (
-
- Online now
- {data.length === 0 ? (
-
- )
-}
-
-// Houses owned by the user's accounts, IDOC first (flagged).
-function Houses({ scope }) {
- const { data } = useAsync(() => scope.houses(), [scope])
- if (!data) return null
- return (
-
- Houses
- {data.length === 0 ? (
-
No houses recorded for this user’s accounts.
- ) : (
-
- {data.map((h) => (
-
- ))}
-
- )}
-
- )
-}
-
// 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 (
- <>
-
- Linked accounts & characters
- `/admin/characters/${serial}`} />
-
-
-
-
- >
- )
-}
-
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
@@ -296,7 +170,10 @@ export default function UserDetail() {
-
+ {/* 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. */}
+
)
}
diff --git a/client/src/routes/admin/views/UserShardSections.jsx b/client/src/routes/admin/views/UserShardSections.jsx
new file mode 100644
index 0000000..aa0890b
--- /dev/null
+++ b/client/src/routes/admin/views/UserShardSections.jsx
@@ -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 (
+
+ {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 (
+
+ Online now
+ {data.length === 0 ? (
+
+ )
+}
+
+// Houses owned by the user's accounts, IDOC first (flagged).
+function Houses({ scope }) {
+ const { data } = useAsync(() => scope.houses(), [scope])
+ if (!data) return null
+ return (
+
+ Houses
+ {data.length === 0 ? (
+
No houses recorded for this user’s accounts.
+ ) : (
+
+ {data.map((h) => (
+
+ ))}
+
+ )}
+
+ )
+}
+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 (
+ <>
+
+ Linked accounts & characters
+ `/admin/characters/${serial}`} />
+
+
+
+
+ >
+ )
+}
diff --git a/client/test/moduleRegistry.test.js b/client/test/moduleRegistry.test.js
index 49c6153..ffd78b1 100644
--- a/client/test/moduleRegistry.test.js
+++ b/client/test/moduleRegistry.test.js
@@ -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',
diff --git a/client/test/moduleSlots.test.js b/client/test/moduleSlots.test.js
new file mode 100644
index 0000000..d0540e4
--- /dev/null
+++ b/client/test/moduleSlots.test.js
@@ -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. `` 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')
+})
diff --git a/server/src/modules/version.js b/server/src/modules/version.js
index f58c441..24b34bc 100644
--- a/server/src/modules/version.js
+++ b/server/src/modules/version.js
@@ -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 }