diff --git a/client/src/App.jsx b/client/src/App.jsx index 3cacb15..ed3c6be 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -5,6 +5,7 @@ import MaintenanceGate from './components/MaintenanceGate.jsx' import RequireAuth from './components/RequireAuth.jsx' import RequirePlayer from './components/RequirePlayer.jsx' import RoleGate from './components/RoleGate.jsx' +import { routesFor } from './modules/registry.js' // Public import Portal from './routes/public/Portal.jsx' @@ -115,6 +116,16 @@ export default function App() { } /> } /> } /> + {/* Installed modules' public pages, namespaced `//…` — 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) => ( + + ))} {/* CMS pages: top-level /:slug, matched only after the named routes above (React Router ranks static routes over this dynamic one). */} } /> @@ -205,6 +216,19 @@ export default function App() { } /> } /> } /> + {/* Installed modules' admin pages, at /admin//…, 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) => ( + {r.element} : r.element} + /> + ))} } /> @@ -225,6 +249,17 @@ export default function App() { } /> } /> } /> + {/* Installed modules' player-portal pages, at /player//…. 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) => ( + {r.element} : r.element} + /> + ))} } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 4312872..068e99b 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -42,6 +42,17 @@ 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 `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 } + export const api = { // ----- auth ----- me: () => req('/auth/me'), diff --git a/client/src/main.jsx b/client/src/main.jsx index dc409af..dd70f9a 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -2,12 +2,54 @@ import React from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' +import { publishSharedDependencies } from './modules/shared.js' import './styles/theme.css' -createRoot(document.getElementById('root')).render( - - - - - , -) +// Publish window.__rg BEFORE rendering and before any module chunk evaluates. +// Installed modules arrive as ``) } // The admin theme as a :root block, or '' when this instance has never been @@ -170,7 +204,26 @@ async function get() { // mean a failing query per page view. overrides = {} } - const html = render(template, 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. + 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. if (generation === startedAt) cached = { html, at: Date.now() } diff --git a/server/test/htmlShell.test.js b/server/test/htmlShell.test.js index 03b5b4d..dbb55af 100644 --- a/server/test/htmlShell.test.js +++ b/server/test/htmlShell.test.js @@ -227,3 +227,86 @@ test('an invalidation during a render is not overwritten by the stale result', a await inflight assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached') }) + +// ── Installed modules' client chunks (MODULE_API.md §3.1) ────────────────── + +// The built shell as Vite actually emits it: core's entry is a module script in +// , and the injection has to land AFTER it wherever it is. +const BUILT_TEMPLATE = ` + + + + Vite App + + + + +
+` + +test('a module chunk is injected as a same-origin module script', () => { + const html = htmlShell.render(TEMPLATE, { moduleEntries: ['/modules/uo/entry.js'] }) + assert.match(html, /', // attribute break-out + '/modules/uo/../secrets.js', + '/uploads/entry.js', // right shape, wrong root + 42, + null, + ], + }) + assert.ok(!html.includes('