From 291c30f6ff81a3541f8bfd73f007e498adfafc07 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 10 Aug 2026 22:03:22 -0500 Subject: [PATCH] feat(modules): publish the installed-module list at /api/v1/public/modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/routes.guards.json | 6 + server/routes.manifest.json | 4 + server/src/router/v1/public/index.js | 7 + .../router/v1/public/modules.controller.js | 60 ++++++ server/src/router/v1/public/modules.router.js | 30 +++ server/swagger/swagger-output.json | 149 +++++++++++++ server/swagger/swagger.js | 25 +++ server/test/publicModules.test.js | 202 ++++++++++++++++++ 8 files changed, 483 insertions(+) create mode 100644 server/src/router/v1/public/modules.controller.js create mode 100644 server/src/router/v1/public/modules.router.js create mode 100644 server/test/publicModules.test.js diff --git a/server/routes.guards.json b/server/routes.guards.json index effc46b..e04bc9c 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -1947,6 +1947,12 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/public/modules", + "handlers": 1, + "gates": [] + }, { "method": "GET", "path": "/api/v1/public/pages/:id/preview/:token", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 3e97f78..5ce03ea 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -781,6 +781,10 @@ "method": "POST", "path": "/api/v1/public/contact" }, + { + "method": "GET", + "path": "/api/v1/public/modules" + }, { "method": "GET", "path": "/api/v1/public/pages/:id/preview/:token" diff --git a/server/src/router/v1/public/index.js b/server/src/router/v1/public/index.js index 4de3329..0737905 100644 --- a/server/src/router/v1/public/index.js +++ b/server/src/router/v1/public/index.js @@ -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 diff --git a/server/src/router/v1/public/modules.controller.js b/server/src/router/v1/public/modules.controller.js new file mode 100644 index 0000000..a1a2b50 --- /dev/null +++ b/server/src/router/v1/public/modules.controller.js @@ -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 `