feat(modules): publish the installed-module list at /api/v1/public/modules #133

Merged
whitlocktech merged 1 commits from feature/module-public-endpoint into edge 2026-08-11 03:16:52 +00:00
Member

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. Docs half: docs#131 (MODULE_API.md §2.9 is the normative shape).

What the payload does not contain is most of the design

GET /api/v1/public/modules    200
{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "1.0.0",
                 "capabilities": ["shard", "atlas", "market"] } ] }
  • started modules only. A disabled or startup_failed module 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 type="module"> per started module (API §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.md §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 while the site is in maintenance.

Why it is a router of its own

/modules is a capability router, not a fifth singleton in site.router.js, and that part is load-bearing. The loader's prefix-collision probe (ownedByCore) reads the live tier stack and skips root-mounted layers, because a use('/', …) matches every path — so a route declared inside the root-mounted site router is invisible to it. Mounting use('/modules', …) is what makes "no module may ever claim /modules" a rule the loader enforces rather than a convention a reviewer has to remember.

Verification

  • 910 tests pass (+9). Every new test is on the boundary — what must not appear: no state, no stage, no reason, no extra key, and a failed module does not hide the ones that started. The suite runs with the pool pointed at a dead port, so "this endpoint never touches a database" is proved by the tests not having to stub a query.
  • routes.manifest.json gains exactly the one route, 229 → 230 (228 public + 2 internal). routes.guards.json records it with an empty gates list, which is itself the assertion that it is ungated.
  • npm run swagger regenerated: the operation plus the PublicModules / PublicModule schemas, nothing else moved.

Phase 2's exit criterion is restated in docs#131 to say what it always meant: no existing URL moves. PR 6 is the single deliberate addition in the phase.

Decisions

All four settled with the org lead before implementation; all four the recommended option.

Decision Settled
Payload + states { modules: [...] }, started only, no state field
Where the route lives Its own /modules capability router
Client chunk URL Omitted — htmlShell injects the tag
Gating No siteMode, DB-free

  • AI-assisted: written with Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8

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. Docs half: **docs#131** (`MODULE_API.md` §2.9 is the normative shape). ## What the payload does not contain is most of the design ```json GET /api/v1/public/modules → 200 { "modules": [ { "id": "uo", "name": "Ultima Online", "version": "1.0.0", "capabilities": ["shard", "atlas", "market"] } ] } ``` - **`started` modules only.** A `disabled` or `startup_failed` module 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 type="module">` per started module (API §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.md` §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 while the site is in maintenance. ## Why it is a router of its own `/modules` is a **capability router**, not a fifth singleton in `site.router.js`, and that part is load-bearing. The loader's prefix-collision probe (`ownedByCore`) reads the live tier stack and skips root-mounted layers, because a `use('/', …)` matches every path — so a route declared inside the root-mounted site router is invisible to it. Mounting `use('/modules', …)` is what makes *"no module may ever claim `/modules`"* a rule the loader enforces rather than a convention a reviewer has to remember. ## Verification - **910 tests pass** (+9). Every new test is on the boundary — what must *not* appear: no `state`, no stage, no reason, no extra key, and a failed module does not hide the ones that started. The suite runs with the pool pointed at a dead port, so "this endpoint never touches a database" is proved by the tests not having to stub a query. - `routes.manifest.json` gains **exactly the one route**, 229 → 230 (228 public + 2 internal). `routes.guards.json` records it with an empty `gates` list, which is itself the assertion that it is ungated. - `npm run swagger` regenerated: the operation plus the `PublicModules` / `PublicModule` schemas, nothing else moved. Phase 2's exit criterion is restated in docs#131 to say what it always meant: no *existing* URL moves. PR 6 is the single deliberate addition in the phase. ## Decisions All four settled with the org lead before implementation; all four the recommended option. | Decision | Settled | |---|---| | Payload + states | `{ modules: [...] }`, `started` only, no state field | | Where the route lives | Its own `/modules` capability router | | Client chunk URL | Omitted — `htmlShell` injects the tag | | Gating | No `siteMode`, DB-free | --- - [x] AI-assisted: written with Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8
wtclaude added 1 commit 2026-08-11 03:04:47 +00:00
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
291c30f6ff
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>
whitlocktech merged commit fe83c91ba9 into edge 2026-08-11 03:16:52 +00:00
whitlocktech deleted branch feature/module-public-endpoint 2026-08-11 03:16:52 +00:00
Sign in to join this conversation.
No description provided.