docs(website): settle what core publishes about an installed module

Phase 2, PR 6 of MODULE_SYSTEM.md 2.7 — GET /api/v1/public/modules.

MODULE_API.md gains 2.9, the normative shape: four fields (id, name, version,
capabilities) in scan order, and what is deliberately absent. Only started
modules appear, so a disabled or failed one is absent exactly as 4.4 already
leaves its routes and nav; no state, failure_stage or failure_reason reaches an
anonymous caller; no client chunk URL, because htmlShell hands the browser the
tag; an empty array is a real answer, but the 7.6 guard is a 500 and never [].
Also records why it owns the /modules prefix rather than sitting in the
root-mounted site router, where the loader's collision probe could not see it.

6.7 amends MODULE_SYSTEM 2.6 step 4, which said the SPA reads the endpoint "to
learn what to load" — step 3 of the same list had already answered loading a
different way, and 3.1.3 is the normative version. The endpoint feature-detects.
2.6 step 4 now says so, and Part 6's intro no longer claims a fixed count of
amendments.

MODULE_SYSTEM.md 2.7 records PR 6 and restates the phase exit criterion
honestly: no EXISTING URL moves, and PR 6 is the single deliberate addition in
Phase 2. Status line updated — the design is in implementation, not unimplemented.

BACKEND_DESIGN.md documents the route in the /public table and the router in the
folder map, and the published api-route-inventory.json mirror is refreshed to
228 public routes (the prose count was stale at 226).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:03:35 -05:00
parent e733ac0c9c
commit 6a39ef63c6
4 changed files with 111 additions and 17 deletions

View File

@@ -90,7 +90,7 @@ rejected rather than ignored, so a typo is a loud failure and not a silently-ine
| `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. |
| `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. |
| `extensions` | no | Core extension slots this module mounts into (§2.4). |
| `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules`, for clients (the SPA, the Android app) to feature-detect against. |
| `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules` (§2.9), for clients (the SPA, the Android app) to feature-detect against. Published only while the module is `started`. |
### 2.2 The entry point
@@ -384,6 +384,42 @@ fragments of started modules into `/api/docs.json`; the full reasoning and the c
§6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered
route has no path in the fragment, and core wins every key collision.
### 2.9 What core publishes about a module
`GET /api/v1/public/modules` — anonymous, database-free, never site-mode gated.
```json
{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "1.0.0",
"capabilities": ["shard", "atlas", "market"] } ] }
```
Four fields, in the loader's scan order (§4.2). What is *not* there is the design:
- **Only `started` modules appear.** The endpoint answers what this backend is serving. A module that
is `disabled` or `startup_failed` is **absent**, which is the same answer §4.4 already gives for its
routes and its nav — a client renders a site without that capability rather than one advertising a
capability that 503s. `installed` and `registered` are likewise absent: neither is serving yet.
- **No `state`, no `failure_stage`, no `failure_reason`.** Where a module broke and how far it got is
operator detail for the admin Modules screen. An anonymous visitor is not told that something is
broken, and the message — which is an exception string from inside core — never leaves the server.
- **No `client` chunk URL.** `utils/htmlShell.js` injects a `<script type="module">` per started
module (§3.1.3), so the browser is handed the tag rather than a URL to fetch. This endpoint is for
**feature detection**, not for loading. `MODULE_SYSTEM.md` §2.6 step 4 predates that resolution and
is amended to match (§6.7).
- **An empty `modules` array is a real answer** — a core with nothing installed. The one thing that is
not an answer is the §7.6 guard: reading the list before `modules.load()` ran is a **500**, never
`[]`, because a caller cannot tell an empty list from a mis-ordered boot.
`capabilities` are opaque to core: it never interprets one, and two modules may declare the same
string. A client must treat an unknown capability as absent and must not infer a route from one — the
mount prefixes are `module.json`'s business (§2.3), not the capability list's.
The endpoint owns the `/modules` prefix on the public tier, which is *why* it is a router of its own
rather than a fifth singleton beside `/settings` and `/version`. The loader's collision probe reads
the live tier stack and skips root-mounted layers (a `use('/', …)` matches every path), so a route
declared inside the root-mounted site router would be invisible to it — a real `use('/modules', …)`
layer is what makes "no module may claim `/modules`" enforced rather than merely intended.
---
## Part 3 — The client contract
@@ -687,8 +723,8 @@ diff the whole workstream is allowed.
## Part 6 — Amendments to MODULE_SYSTEM.md
Four things the survey found that the design of record gets wrong or does not cover. The first
needs a decision.
Things the survey found that the design of record gets wrong or does not cover, plus what
implementation has since amended. The first needed a decision and has one.
### 6.1 OpenAPI generation does not survive a dynamic loader — **settled: fragment merge**
@@ -815,6 +851,21 @@ stays reproducible on any machine regardless of what is installed.
from. A module needs no equivalent — it ships a prebuilt `swagger-fragment.json`, because core never
has its sources to analyse.
### 6.7 `/api/v1/public/modules` is not the client's load trigger
`MODULE_SYSTEM.md` §2.6 step 4 says "the SPA reads `/api/v1/public/modules` to learn what to load,
then registers routes, nav and its feature provider". Step 3 of the same list resolved the loading
question a different way, and §3.1.3 here is the normative version: `htmlShell.js` injects a
`<script type="module" src="/modules/<id>/entry.js">` per **started** module, so the browser is handed
the tag by the document and never fetches a URL the endpoint told it about. Nothing waits on an API
round trip to start loading, which is also why the tag can be in `<head>`.
What the endpoint is for is **feature detection**: capabilities for the SPA and the Android app, which
has no chunk to load at all. §2.9 is the shape. The two statements were only ever in tension because
§2.6 was written before the CSP constraint forced the injected-tag design; step 4 should read "the SPA
reads `/api/v1/public/modules` to feature-detect", and the registration it describes happens when the
injected chunk executes and calls `window.__rg.registry` (§3.3).
---
## Part 7 — What the spike proved