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:
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–6 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user