docs(website): add the module system design of record #122

Merged
whitlocktech merged 1 commits from docs/module-system into main 2026-08-10 07:19:37 +00:00
Member

Adds website/MODULE_SYSTEM.md — the design of record for turning Runic Gateway from a UO-specific platform into a game-agnostic one. Game-specific routes, tables, screens and nav leave the core website and become an installable module. An operator installs the base site, installs the module for their game, and restarts. They never build anything — the WordPress-plugin model, and the constraint that drives most of the design.

Docs only. No code changes, nothing implemented.

What this is

The draft plan was checked line by line against the working trees of website/, link/, servuo-plugins/, installer/ and docs/. Part 1 of the document records what is actually there; Part 2 is the plan that survives it; Part 3 lists the ten settled decisions.

What the draft got right

The extraction really is close to a folder move, and that is now verified rather than assumed:

  • 8 of 31 model directories are UO, cleanly separated
  • all 13 UO router/controller files are single-purpose with no shared code
  • router/v1/{public,admin,player}/index.js are pure mount tables — module mounting drops straight in
  • the nine UO utils/ files import only four things from core, which is where the API surface comes from

What did not survive verification

Draft assumption Reality
One flat module prefix (/api/v1/game/uo) Contradicts URL preservation — UO routes span three access tiers. Modules own a named slot per tier instead
registerModuleNav as a server API There is no server-side nav list, and navOverrides.js:13-21 explicitly refuses to have one. Nav registration is client-side
Nav is a small clean extension UO items interleave into core groups, and the feature: gate mechanism is itself the shard visibility system
Module-scoped migration runner There is no migration system at all — schema.sql is replayed idempotently every boot. Modules ship fragments
Routes + nav + migrations cover it server.js holds eight UO call sites with no hook to receive them → onBoot/onShutdown
Everything is a clean move notificationStreams.js, pushDispatch.fromShardEvent and announceWorker's town-crier leg are entangled and need registries
Three mount shapes A fourth: six UO routes nested under the core users resource, and GET /admin/users/:id served by the UO controller
Bot has UO logic to extract Zero matches in website/bot/src. Nothing to extract
Installer delivers modules installer/src/cli.rs:162"The installer never contacts your website" — and Bundle is hardcoded to two non-optional components
SHA256SUMS trust anchor Does not exist. It is a per-asset sha256 inside a bundle JSON over anonymous HTTPS, no signatures
DB-driven async module loader routeManifest.js and swagger.js both walk app.js with no DB — modules must mount synchronously from the filesystem
Build-time client inclusion Production is a prebuilt pull-only image and operators must not build. Client half ships as a prebuilt ESM chunk

The client-loading decision

Three constraints had to hold at once: the operator builds nothing, production pulls a prebuilt image (Dockerfile bakes client/dist, compose has no build:), and config/csp.js:49 sets script-src 'self' with no 'unsafe-inline' — which kills import maps, since those must be inline.

What survives all three: the module's own CI builds its client half in Vite library mode with React externalised; core hands React over on a global (window.__rg) rather than an import map, so there is exactly one React instance; and htmlShell.js:111 — which already rewrites </head> — injects a same-origin <script type="module" src>, which 'self' allows with no nonce. Modules live on a mounted volume like uploads already does.

Phase 1 prototypes this end to end before anything is committed to it, since it is the highest-risk call in the plan.

Deliberate breaking change

SPA pages get namespaced — /uo/*, /admin/uo/*, /player/uo/* — with no redirects and no nav-override migration, on the basis that the site is not public yet. API URLs are unaffected, so the Android app and Discord bot need no API changes.

Two accepted consequences are recorded in §2.8: stored nav-override rows are keyed by path and must be redone, and android-app/.../NavPaths.kt holds ten /site/* constants that will need updating in the separate Android plan. App Links verification is unaffected — its intent filters only cover /mobile/callback.

Phasing

  1. API contract + spike (blocking)/api/v1/public/atlas/* behind the proposed surface, plus the chunk load under the enforced CSP
  2. Core scaffolding — loader, state machine, schema fragments, the three registries, boot hooks, /api/v1/public/modules, client registry. Zero URL changes or it is wrong
  3. Extract module-uo — 25 tables, ~35 files, four acceptance criteria including a CI grep test for UO identifiers left in core
  4. Delivery — admin panel + Docker environment, deliberately last

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

🤖 Generated with Claude Code

https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8

Adds `website/MODULE_SYSTEM.md` — the design of record for turning Runic Gateway from a UO-specific platform into a game-agnostic one. Game-specific routes, tables, screens and nav leave the core website and become an installable **module**. An operator installs the base site, installs the module for their game, and restarts. **They never build anything** — the WordPress-plugin model, and the constraint that drives most of the design. Docs only. No code changes, nothing implemented. ## What this is The draft plan was checked line by line against the working trees of `website/`, `link/`, `servuo-plugins/`, `installer/` and `docs/`. Part 1 of the document records what is actually there; Part 2 is the plan that survives it; Part 3 lists the ten settled decisions. ## What the draft got right The extraction really is close to a folder move, and that is now verified rather than assumed: - 8 of 31 model directories are UO, cleanly separated - all 13 UO router/controller files are single-purpose with no shared code - `router/v1/{public,admin,player}/index.js` are pure mount tables — module mounting drops straight in - the nine UO `utils/` files import only four things from core, which is where the API surface comes from ## What did not survive verification | Draft assumption | Reality | | --- | --- | | One flat module prefix (`/api/v1/game/uo`) | Contradicts URL preservation — UO routes span three access tiers. Modules own a named slot **per tier** instead | | `registerModuleNav` as a server API | There is no server-side nav list, and `navOverrides.js:13-21` explicitly refuses to have one. Nav registration is client-side | | Nav is a small clean extension | UO items interleave into **core** groups, and the `feature:` gate mechanism is itself the shard visibility system | | Module-scoped migration runner | There is no migration system at all — `schema.sql` is replayed idempotently every boot. Modules ship **fragments** | | Routes + nav + migrations cover it | `server.js` holds **eight** UO call sites with no hook to receive them → `onBoot`/`onShutdown` | | Everything is a clean move | `notificationStreams.js`, `pushDispatch.fromShardEvent` and `announceWorker`'s town-crier leg are entangled and need registries | | Three mount shapes | A fourth: six UO routes nested under the **core** users resource, and `GET /admin/users/:id` served by the UO controller | | Bot has UO logic to extract | Zero matches in `website/bot/src`. Nothing to extract | | Installer delivers modules | `installer/src/cli.rs:162` — *"The installer never contacts your website"* — and `Bundle` is hardcoded to two non-optional components | | SHA256SUMS trust anchor | Does not exist. It is a per-asset `sha256` inside a bundle JSON over anonymous HTTPS, no signatures | | DB-driven async module loader | `routeManifest.js` and `swagger.js` both walk `app.js` with **no DB** — modules must mount synchronously from the filesystem | | Build-time client inclusion | Production is a prebuilt pull-only image and operators must not build. Client half ships as a **prebuilt ESM chunk** | ## The client-loading decision Three constraints had to hold at once: the operator builds nothing, production pulls a prebuilt image (`Dockerfile` bakes `client/dist`, compose has no `build:`), and `config/csp.js:49` sets `script-src 'self'` with no `'unsafe-inline'` — which kills import maps, since those must be inline. What survives all three: the module's own CI builds its client half in Vite library mode with React externalised; core hands React over on a **global** (`window.__rg`) rather than an import map, so there is exactly one React instance; and `htmlShell.js:111` — which already rewrites `</head>` — injects a **same-origin** `<script type="module" src>`, which `'self'` allows with no nonce. Modules live on a mounted volume like `uploads` already does. Phase 1 prototypes this end to end before anything is committed to it, since it is the highest-risk call in the plan. ## Deliberate breaking change SPA pages get namespaced — `/uo/*`, `/admin/uo/*`, `/player/uo/*` — with **no redirects and no nav-override migration**, on the basis that the site is not public yet. **API URLs are unaffected**, so the Android app and Discord bot need no API changes. Two accepted consequences are recorded in §2.8: stored nav-override rows are keyed by path and must be redone, and `android-app/.../NavPaths.kt` holds ten `/site/*` constants that will need updating in the separate Android plan. App Links verification is unaffected — its intent filters only cover `/mobile/callback`. ## Phasing 1. **API contract + spike (blocking)** — `/api/v1/public/atlas/*` behind the proposed surface, plus the chunk load under the enforced CSP 2. **Core scaffolding** — loader, state machine, schema fragments, the three registries, boot hooks, `/api/v1/public/modules`, client registry. Zero URL changes or it is wrong 3. **Extract `module-uo`** — 25 tables, ~35 files, four acceptance criteria including a CI grep test for UO identifiers left in core 4. **Delivery** — admin panel + Docker environment, deliberately last --- - [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-10 06:45:13 +00:00
Turns Runic Gateway from a UO-specific platform into a game-agnostic one:
game-specific routes, tables, screens and nav leave the core website and
become an installable module. Operators install the base site, install the
module for their game, and restart -- they never build anything.

Verified against the working trees rather than written from the draft. The
draft's load-bearing assumptions that did not survive:

  * one flat module route prefix cannot coexist with URL preservation, since
    UO routes span three access tiers -- modules own a named slot per tier
  * there is no server-side nav list, and navOverrides.js refuses to have one,
    so nav registration is client-side
  * the nav feature-gate mechanism is itself the shard visibility system
  * there is no migration system to model a module runner on -- schema.sql is
    replayed idempotently every boot, so modules ship fragments
  * boot/shutdown holds eight UO call sites with no hook to receive them
  * notificationStreams, pushDispatch and announceWorker are entangled, not moves
  * six UO routes are nested under the core users resource
  * the Discord bot has no UO logic at all
  * the installer never contacts the website and its Bundle is hardcoded to
    two components, so delivery is website-side
  * routeManifest and swagger walk app.js with no DB, so modules mount
    synchronously from the filesystem
  * production is a prebuilt pull-only image and CSP forbids inline script,
    which together decide how the client half loads

Ten decisions are recorded as settled in Part 3.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 53b9a2347a into main 2026-08-10 07:19:37 +00:00
whitlocktech deleted branch docs/module-system 2026-08-10 07:19:37 +00:00
Sign in to join this conversation.
No description provided.