feat(modules): publish the installed-module list at /api/v1/public/modules
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m33s
PR Checks / bot-install (pull_request) Successful in 8m45s

Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md 2.7 — the first module-system
URL a client can see. The SPA and the Android app feature-detect against the
capabilities a module declares; the shape is settled in MODULE_API.md 2.9.

Four decisions, and what is absent from the payload is most of the design:

* started modules only. A module that is disabled or failed to load is
  ABSENT, exactly as 4.4 already leaves its routes and its nav absent, so a
  client renders a site without that capability rather than advertising one
  that 503s.
* no state, failure_stage or failure_reason. Where a module broke belongs to
  the admin Modules screen, and the reason is an exception string from inside
  core — not anonymous-visitor business.
* no client chunk URL. htmlShell injects a script tag per started module
  (3.1.3), so the browser is handed the tag rather than a URL to fetch. This
  endpoint feature-detects; it does not load. MODULE_SYSTEM 2.6 step 4 is
  amended to match (API 6.7).
* no siteMode gate and no database — the same class as /public/status and
  /public/version, so a client can still feature-detect during maintenance.

It is a capability router of its own rather than a fifth singleton in
site.router.js, and that is load-bearing: the loader's prefix-collision probe
reads the live tier stack and skips root-mounted layers, because a use('/', ...)
matches every path. A route inside the root-mounted site router would be
invisible to it — mounting use('/modules', ...) is what makes "no module may
claim /modules" a rule the loader enforces.

910 tests pass (+9, every one on the boundary — what must NOT appear).
routes.manifest.json gains exactly the one route and routes.guards.json records
it with an empty gates list, which is itself the assertion that it is ungated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:03:22 -05:00
parent 85f563fc16
commit 291c30f6ff
8 changed files with 483 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const shardRouter = require('./shard.router')
const atlasRouter = require('./atlas.router')
const modulesRouter = require('./modules.router')
const siteRouter = require('./site.router')
const publicRouter = express.Router()
@@ -38,6 +39,12 @@ publicRouter.use('/shard', shardRouter)
// here depends on the bridge — and site-mode gated per route like the content
// routers above, which is the other half of that distinction.
publicRouter.use('/atlas', atlasRouter)
// What this backend serves beyond core. A real prefix layer rather than a fifth
// singleton in site.router.js, because the loader's prefix-collision probe reads
// the live tier stack and skips root-mounted layers — this mount is what makes
// /modules unclaimable by a module. Never site-mode gated: a client must be able
// to feature-detect while the site is in maintenance.
publicRouter.use('/modules', modulesRouter)
// The four singletons that own no path segment of their own: /settings, /status,
// /version and /contact. Mounted at the group root, last — safe only because

View File

@@ -0,0 +1,60 @@
// Public · Modules — what this backend is currently serving beyond core.
//
// Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md §2.7. The published shape is
// settled in MODULE_API.md §2.1 (`capabilities` are opaque strings, published
// here, for clients to feature-detect against).
//
// Two decisions are visible in the ten lines below and are the whole of this
// file's design:
//
// • **`started` only.** The public surface answers "what is serving", and
// nothing else. A module that failed to load, or that an operator disabled,
// is simply ABSENT — the same treatment §4.4 already gives its routes and
// its nav, so an anonymous visitor sees a site without that capability
// rather than a site advertising a capability that 503s. `state`, the
// failure stage and the failure reason are core's business and belong to the
// admin Modules screen; none of the three is published here.
// • **No database, and no siteMode gate.** The answer comes from the loader's
// in-memory records, so this endpoint keeps working with the database down —
// the same class as /public/version and /public/status, both of which must
// answer during maintenance so a client can bootstrap and render the
// maintenance page. A client that could not feature-detect while the site
// was in maintenance would render its maintenance page as though no module
// existed.
//
// This endpoint is deliberately NOT how a module's client chunk gets loaded.
// `utils/htmlShell.js` injects a `<script type="module">` per started module
// (MODULE_API.md §3.1.3), so the browser is handed the tag rather than a URL to
// go and fetch; there is no `client` field here for the same reason there is no
// second copy of any other fact. See MODULE_SYSTEM.md §2.6, amended to match.
const loader = require('../../../modules/loader')
const log = require('../../../utils/logger')('public:modules')
/** id, name, version and capabilities — everything else the loader knows is internal. */
const publish = (m) => ({
id: m.id,
name: m.name,
version: m.version,
capabilities: m.capabilities,
})
function getModules(req, res) {
try {
// Scan order (alphabetical by id) comes from the loader and is preserved:
// there is no dependency resolution, so any other order would imply a
// precedence nothing computes (MODULE_API.md §4.2).
return res.json({ modules: loader.list().filter((m) => m.state === 'started').map(publish) })
} catch (err) {
// The only reachable throw is §7.6's guard — the module list read before
// modules.load() ran. That is a mis-ordered boot, not a bad request, so it
// is logged rather than answered with an empty list: `{ modules: [] }` is a
// true answer for a core with no modules installed and a caller cannot tell
// the two apart.
log.error('module list unavailable', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getModules }

View File

@@ -0,0 +1,30 @@
// Public · Modules — the installed-module list a client feature-detects against.
//
// Mounted at /api/v1/public/modules by public/index.js. One route, and it owns a
// prefix rather than sitting beside /settings and /version in site.router.js —
// which is the point of the file existing at all. The loader asks the LIVE
// public tier router whether a prefix is already core's (`ownedByCore`,
// modules/loader.js), and it skips root-mounted layers because a `use('/', …)`
// matches every path. A route declared inside the root-mounted site router is
// therefore invisible to that probe; a real `use('/modules', …)` layer is not.
// So mounting it here is what makes "no module may ever claim /modules" an
// enforced rule instead of a convention.
//
// No siteMode gate and no database — see modules.controller.js for why.
const express = require('express')
const ctrl = require('./modules.controller')
const modulesRouter = express.Router()
modulesRouter.get(
'/',
// #swagger.tags = ['Public']
// #swagger.summary = 'Installed modules (id, version, capabilities)'
// #swagger.description = 'The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.'
/* #swagger.responses[200] = { description: 'The started modules', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicModules" } } } } */
ctrl.getModules,
)
module.exports = modulesRouter