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('