diff --git a/client/src/api/client.js b/client/src/api/client.js index 068e99b..0051b81 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -51,7 +51,10 @@ function safeParse(text) { // 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. -export { req as request } +// `BASE` goes with it: a module that needs an EventSource URL cannot go through +// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of +// mount point and not a promise it has made. +export { req as request, BASE } export const api = { // ----- auth ----- diff --git a/client/src/main.jsx b/client/src/main.jsx index f1c81fa..c2c3101 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,10 +3,7 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.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 { declareSlot } from './modules/registry.js' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -17,18 +14,12 @@ import './styles/theme.css' // (docs/website/MODULE_API.md §3.2). 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. -// -// 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) +// Core registered a feature provider here until slice 3, under owner id `core` +// and namespace `uo`, so that the seam was exercised by real content from the +// day it was built. That prediction paid out exactly as written: the extraction +// deleted the registration and the hook it named, and SiteHeader was not touched. +// There is nothing for core to register now — no core nav row carries a +// `feature` — and the filter is a correct no-op until a module supplies one. // ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── // @@ -48,15 +39,22 @@ declareSlot('site.footer.status') // 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') +// The invite-acceptance page's optional next step. Core owns invites — staff are +// invited too — and owned the game-account step inside them until slice 3, which +// meant core reading a `gameAccountSignup` flag and posting to a shard route. +// +// Named for the place, like the other two: it is "the point after an invite has +// been accepted and before the invitee is sent on", not "create a game account". +// Whether there is a step at all is the filling module's decision, made from +// data core does not have; core renders the shell and a skip control, and hands +// over `onDone`. With the slot unfilled the invitee goes straight to the portal, +// which is what core's own code did whenever the flag was off. +declareSlot('player.invite.accepted') -// 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) +// Core filled the first two itself until slice 3, with the components that were +// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills +// all three, and core's own fills had to go for it to be able to — the first +// fill wins, and core registered first (§3.7). // 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/featureGate.js b/client/src/modules/featureGate.js index dc30912..4c6091c 100644 --- a/client/src/modules/featureGate.js +++ b/client/src/modules/featureGate.js @@ -3,22 +3,22 @@ // 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. +// Nine of the sixteen rows in the public header used to carry a `feature`, and +// every one of them was a shard surface an admin can disable or gate to a higher +// audience. The provider that answered those questions moved out with the module +// in Phase 3 slice 3, and core cannot call it directly and still be a core. It +// keeps this 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. +// carry no `moduleId` and resolve against the owner id `core` — which nothing +// registers now that the shard rows are gone, and that is the correct resting +// state rather than a gap: no core nav row carries a `feature`. // -// Everything here fails OPEN, and that is deliberate and unchanged from -// useShardFeatures' own posture: this is presentation, the gate is server-side +// Everything here fails OPEN, and that is deliberate: 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 diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js index 87d7a03..d6783f5 100644 --- a/client/src/modules/registry.js +++ b/client/src/modules/registry.js @@ -79,8 +79,18 @@ export function registerRoutes(id, byArea) { * 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. * + * `icon` is a component core renders exactly as it renders its own rows' icons + * (1.3.0). It exists because without it the six UO rows would have extracted as + * the only text-only entries in a sidebar where every other row has a glyph, + * which reads as breakage rather than as a design. Core does not supply a + * fallback: a module that omits it gets no icon, the same as a core row that + * omits it, and inventing one would be core making a presentation choice for + * content it knows nothing about. Note that `icon` is already among the fields + * an override may not touch (lib/navOverrides.js) — the concept predates a + * module being able to supply one. + * * @param {string} id - * @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec + * @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} spec */ export function registerNav(id, spec) { const { area, items } = spec || {} @@ -92,10 +102,10 @@ export function registerNav(id, spec) { /** * 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 - * module installed the nav filter is a correct no-op, because no core nav item - * carries a `feature` today. + * Core keeps a generic flag context and owns none of the semantics + * (MODULE_SYSTEM.md §1.5). With no module installed the nav filter is a correct + * no-op, because no core nav item carries a `feature` — which has been literally + * true since Phase 3 slice 3 took the nine shard-gated rows out. */ export function registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) diff --git a/client/src/modules/shared.js b/client/src/modules/shared.js index 03a4ad7..9d2a1df 100644 --- a/client/src/modules/shared.js +++ b/client/src/modules/shared.js @@ -37,7 +37,7 @@ import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx' import { useAsync } from '../lib/useAsync.js' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' -import { request, ApiError } from '../api/client.js' +import { request, ApiError, BASE } 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: @@ -66,11 +66,16 @@ 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. -const api = { request, ApiError } +// The request PRIMITIVE, not the `api` object (§3.5): 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. +// +// `BASE` was in §3.5 from the start and missing from this object until slice 3, +// which is when something first needed it. `request` is fetch-only, so an +// EventSource — the shard's live feed is two of them — has to build its own URL, +// and the alternative is a module hardcoding `/api/v1`: an assertion about where +// core mounts its API that core has never promised to keep. +const api = { request, ApiError, BASE } /** * Publish `window.__rg`. Called by main.jsx before it renders, and before any diff --git a/client/src/modules/version.js b/client/src/modules/version.js index 466b4c9..9150dc2 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,12 @@ // 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.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may +// carry an `icon` component (§3.3), core declares a third slot +// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which +// §3.5 always documented and shared.js never published. Additive throughout: a +// module written against 1.2.0 is unaffected. The server half is untouched and +// bumps anyway, for the reason below. // 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 @@ -20,4 +26,4 @@ // 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.2.0' +export const MODULE_API_VERSION = '1.3.0' diff --git a/client/src/routes/player/AcceptInvite.jsx b/client/src/routes/player/AcceptInvite.jsx index 84a82db..3271bb1 100644 --- a/client/src/routes/player/AcceptInvite.jsx +++ b/client/src/routes/player/AcceptInvite.jsx @@ -1,14 +1,22 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useAuth } from '../../contexts/AuthContext.jsx' import { api } from '../../api/client.js' import PlayerShell, { honeypotStyle } from './PlayerShell.jsx' -import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx' +import Slot from '../../modules/Slot.jsx' +import { extensionFor } from '../../modules/registry.js' // Public, token-gated invite acceptance (/invite/:token). Validates the invite, // lets the invitee set a username + password (their email + role are pre-assigned), -// creates the account at that role and logs them in. For a player invite it then -// offers the built-in "create game account" step before sending them to the portal. +// creates the account at that role and logs them in. +// +// For a PLAYER invite there may then be one more step, supplied by an installed +// module through the `player.invite.accepted` slot: core rendered a UO +// game-account form here itself until Phase 3 slice 3, reading a +// `gameAccountSignup` flag out of its own settings and posting to a shard route. +// Neither of those is core's. What core keeps is the shell, the skip control and +// the destination; whether there is a step at all is the module's call, made +// from data core does not have. export default function AcceptInvite() { const { token } = useParams() const navigate = useNavigate() @@ -16,7 +24,6 @@ export default function AcceptInvite() { const [invite, setInvite] = useState(null) // fields email and role const [loadErr, setLoadErr] = useState('') - const [signupOk, setSignupOk] = useState(false) const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -30,14 +37,20 @@ export default function AcceptInvite() { api.getInvite(token) .then((iv) => active && setInvite(iv)) .catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.')) - api.publicSettings() - .then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup))) - .catch(() => {}) return () => { active = false } }, [token]) const dest = invite && invite.role === 'player' ? '/player' : '/admin' + // Whether anything is installed that wants the post-acceptance step. Read + // rather than rendered blind because it decides a NAVIGATION, not just what + // appears: with nothing filled there is no screen to show, so the invitee goes + // straight to their destination. This is the one legitimate reason to ask + // whether a slot is filled — the answer changes control flow, not decoration + // (decoration goes inside ``, which is why `hasExtension` is gone). + const hasNextStep = Boolean(extensionFor('player.invite.accepted')) + const finish = useCallback(() => navigate('/player', { replace: true }), [navigate]) + async function onSubmit(e) { e.preventDefault() setError('') @@ -48,8 +61,9 @@ export default function AcceptInvite() { await api.acceptInvite(token, username.trim(), password, { company }) await refresh() // pull the freshly-issued session into context setAccepted(true) - // Staff invites are web-only — no game step; go straight in. - if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true }) + // Staff invites go straight in, and so does a player invite when nothing + // is installed that has a step to offer. + if (!(invite.role === 'player' && hasNextStep)) navigate(dest, { replace: true }) } catch (err) { if (err.status === 409) setError('That username is already taken, or the invite was already used.') else if (err.status === 404) setError('This invitation is invalid or has expired.') @@ -78,19 +92,22 @@ export default function AcceptInvite() { ) } - // ── Accepted: optional game-account step (player invites) ────────────────── + // ── Accepted: a module's optional next step (player invites) ─────────────── + // + // Only reachable when the slot is filled — `onSubmit` navigates away otherwise + // — so there is no empty-shell case to guard here. + // + // The subtitle is core's and says nothing about what the step is: naming it + // would be core describing content it does not own, and the wrong description + // is worse than a general one. "Skip" stays core's too, because where it goes + // is core's decision, and it is rendered outside the slot deliberately — an + // extension that throws must not take the way out with it. if (accepted) { return ( - -

- Your account is ready. Create a game account now to play, or skip and do it later from your portal. -

- navigate('/player', { replace: true })} - /> + +

-

diff --git a/client/test/moduleNav.test.js b/client/test/moduleNav.test.js index 011dd02..c1209db 100644 --- a/client/test/moduleNav.test.js +++ b/client/test/moduleNav.test.js @@ -132,6 +132,36 @@ test('a module row carries its moduleId through, which is how the gate finds it' assert.equal(row.feature, 'atlas') }) +test('a module row carries its icon through, and an override cannot touch it', () => { + // 1.3.0. Without an `icon` the six extracted UO rows would have been the only + // text-only entries in a sidebar where every other row has a glyph. Core + // renders whatever component the row carries and supplies no fallback — an + // invented one would be core making a presentation choice for content it knows + // nothing about. + const Glyph = () => null + registerNav('uo', { + area: 'admin', + items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System', icon: Glyph }], + }) + const merged = withModuleNav(ADMIN, 'admin') + const row = merged.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link') + assert.equal(row.icon, Glyph) + + // `icon` was already on navOverrides' list of fields an override may not + // touch, from long before a module could supply one. It still is. + const overridden = applyNavOverrides(merged, { '/admin/uo/link': { label: 'Renamed', icon: 'nope' } }) + const after = overridden.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link') + assert.equal(after.label, 'Renamed') + assert.equal(after.icon, Glyph) +}) + +test('a row with no icon simply has none, the same as a core row with none', () => { + registerNav('uo', { area: 'player', items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }] }) + const row = withModuleNav([{ to: '/account', label: 'Account' }], 'player')[0] + assert.equal(row.to, '/player/uo/characters') + assert.equal(row.icon, undefined) +}) + 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) diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 24b34bc..031ae8d 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,11 @@ // 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.3.0 — three CLIENT additions from Phase 3 slice 3: a nav item may carry an +// `icon`, core declares a `player.invite.accepted` slot, and `window.__rg.api` +// gained `BASE` (which §3.5 always specified and shared.js never published). +// Nothing on the server changed; this file bumps for the reason below. +// // 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 @@ -21,6 +26,6 @@ // 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.2.0' +const MODULE_API_VERSION = '1.3.0' module.exports = { MODULE_API_VERSION }