Merge pull request 'docs(website): settle what core publishes about an installed module' (#131) from docs/module-public-endpoint into main

Reviewed-on: #131
This commit is contained in:
2026-08-11 03:16:23 +00:00
4 changed files with 111 additions and 17 deletions

View File

@@ -105,6 +105,12 @@ server/
NOT under /shard: nothing here
touches the sidecar, and unlike
/shard it IS site-mode gated
modules.router.js (1) /public/modules — the installed-module
list a client feature-detects against.
A real prefix and not a fifth singleton
below, so the module loader's
collision probe (which skips
root-mounted layers) sees it
site.router.js (4) /settings /status /version /contact —
the group-root singletons; declares no
router-level middleware
@@ -715,7 +721,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 226 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 228 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
@@ -898,6 +904,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL); these are **effective** values, so an admin theme (`theme_visual`) beats `BRAND_ACCENT_COLOR` and an uploaded `brand_assets` asset beats its `BRAND_*` path — an optional **`theme`** block, the resolved CSS custom properties for that admin theme (absent when the instance was never themed, which is what makes it render from the shipped stylesheet unchanged) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/modules` | `{ modules: [{ id, name, version, capabilities }] }` — the modules this backend is currently **serving**, in scan order (module system, `MODULE_API.md` §2.9). A module that is disabled or failed to load is **absent**, not listed with a state: its routes and nav are absent too, so the client renders a site without that capability rather than advertising one that 503s. The recorded failure stage and reason are admin-panel detail and are never published here. `capabilities` are opaque strings the module declares — feature-detect against them and treat an unknown one as absent. Like `/status` and `/version` it is **DB-free and not site-mode gated**, so a client can still feature-detect during maintenance. It is *not* how a module's client chunk loads — `htmlShell` injects a `<script type="module">` per started module. |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) |

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

View File

@@ -1,12 +1,13 @@
# The Module System — design of record
**Status:** approved design, not yet implemented. Every decision in Part 3 has been settled with the
org lead; Part 1 records what was verified against the working trees on 2026-08-10, including the
places the original draft was wrong.
**Status:** approved design, **in implementation** — Phase 2's core scaffolding is landing on the
website `edge` branch, PRs 16 of 9 done (§2.7 tracks what each settled). Every decision in Part 3
has been settled with the org lead; Part 1 records what was verified against the working trees on
2026-08-10, including the places the original draft was wrong.
**The normative contract is [`MODULE_API.md`](MODULE_API.md)** (Phase 1). This document decides what
the module system *is*; that one decides exactly what a module may call. Where the two differ, that
one wins — its Part 6 lists the four places it amends this document.
one wins — its Part 6 lists every place it amends this document.
**Goal.** Turn Runic Gateway from a UO/ServUO-specific platform into a game-agnostic one. The core
architecture is unchanged — sidecar → website → browser. What changes is that game-specific
@@ -445,8 +446,12 @@ builds nothing, production pulls a prebuilt image, and `script-src 'self'` forbi
(`utils/htmlShell.js:111`), so this is an extension of a working mechanism, not a new one. The tag
is `<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
nonce and no inline.
4. **The SPA reads `/api/v1/public/modules`** to learn what to load, then registers routes, nav and
its feature provider through `window.__rg.registry`.
4. **The SPA reads `/api/v1/public/modules`** to feature-detect against what this backend is
serving. Registration happens when the injected chunk executes and calls `window.__rg.registry`
it is not gated on this call. *(Amended by [`MODULE_API.md`](MODULE_API.md) §6.7: this step
originally said the SPA reads the endpoint "to learn what to load", which step 3 above had
already answered a different way. Nothing waits on an API round trip to start loading. The
endpoint's shape is API §2.9.)*
Phase 1 prototypes exactly this before anything is committed to it (§2.7).
@@ -457,7 +462,7 @@ Phase 1 prototypes exactly this before anything is committed to it (§2.7).
Nothing else can be trusted until the first of these is done.
**Phase 1 — API contract + spike (blocking).** Merge this document. Write the contract at
[`docs/website/MODULE_API.md`](MODULE_API.md) — **done**; it amends this document in four places,
[`docs/website/MODULE_API.md`](MODULE_API.md) — **done**; the places it amends this document are
listed in its Part 6, one of which (OpenAPI generation, §6.1 there) needs a decision before Phase 2
starts. Then a throwaway spike on an unmerged branch moving **`/api/v1/public/atlas/*`** behind the
proposed surface — the smallest honest test: six routes, DB-backed, no sidecar, no SSE, one boot
@@ -480,18 +485,21 @@ too (API §7.2).
3. `ensureSchema()` extended to replay module fragments after core's.
4. The three de-entanglement registries (§1.8), with core still the only registrant.
5. Boot/shutdown hook dispatch in `server.js`, likewise.
6. `GET /api/v1/public/modules` — installed ids, versions and capabilities, shaped like the existing
branding/site-settings endpoint. The SPA needs it to know what to load; the Android plan consumes
the same endpoint.
6. `GET /api/v1/public/modules` — ids, names, versions and capabilities of the modules currently
**serving**, shaped like the existing branding/site-settings endpoints (anonymous, database-free,
not site-mode gated). The SPA and the Android plan both feature-detect against it; it is not what
loads a client chunk ([`MODULE_API.md`](MODULE_API.md) §2.9 and §6.7).
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, and the
`htmlShell` script injection — empty registry, no visible change.
8. `MOD_PATHS` → `roles`-derived (§1.4); the generic feature-provider seam (§1.5).
9. `docker-compose.yml` gains the `modules` volume.
Exit criterion: `routes.manifest.json` diff is zero lines and every existing test passes. If Phase 2
changes one URL, it is wrong.
Exit criterion: no **existing** URL moves and every existing test passes. If Phase 2 changes one URL,
it is wrong. PR 6 is the single deliberate exception in the phase and it *adds*: `routes.manifest.json`
gains exactly one line, `GET /api/v1/public/modules`, and nothing else in the file moves. Every other
PR in Phase 2 produces a zero-line diff.
**Progress: PRs 1-5 done.**
**Progress: PRs 1-6 done.**
- **PR 1** — `installed_modules` and the state machine, with the stored shape and the boot rules
settled in §2.4 above.
@@ -561,6 +569,30 @@ fixed enum because the leg set is whatever has been registered.
Still nothing on the volume: **900 tests pass**, `routes.manifest.json` is unchanged at 229 routes
and the OpenAPI spec regenerates byte-identical.
- **PR 6** — `GET /api/v1/public/modules`, the first module-system URL a client can see. Four
decisions, all recorded in [`MODULE_API.md`](MODULE_API.md) §2.9: **`started` modules only**, so a
disabled or failed module is absent exactly as its routes and nav already are, and no visitor is
told that something is broken; **no `state`, `failure_stage` or `failure_reason`** on the public
surface — those are the admin screen's, and the reason is an exception string from inside core;
**no `client` chunk URL**, because `htmlShell` hands the browser the tag rather than a URL to fetch
(which amends §2.6 step 4 above — see API §6.7); and **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.
It is a **capability router of its own** rather than a fifth singleton in `site.router.js`, and that
is the load-bearing part. The loader's prefix-collision probe 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.
**910 tests pass** (+9, every one of them on the boundary: what must *not* appear). The route
inventory goes 229 → 230 (228 public + 2 internal) and moves by exactly the one added route;
`routes.guards.json` records it with an empty `gates` list, which is itself the assertion that the
endpoint is ungated. The OpenAPI spec gains the operation and the `PublicModules`/`PublicModule`
schemas. The published mirror [`api-route-inventory.json`](./api-route-inventory.json) is refreshed
to match.
**Phase 3 — Extract `module-uo`.** Moves out of `website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus

View File

@@ -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"