docs(website): Phase 2 PR 9 — the modules mount, and Phase 2 complete

Records the last Phase 2 PR and closes the phase.

Amends §2.5: the mount is a bind mount of ./modules, not the named volume the
section reached for by analogy with uploads. Hand-placing a module directory is
a supported install in that same section, and a named volume routes it through
`docker cp` — the least discoverable mechanism Docker offers, for the one
install path an operator without the admin panel has.

Two things the build settled that the plan had not considered, both silent
failures rather than errors: the directory has to be tracked, because Docker
recreates a missing bind-mount source as root-owned and the container is uid
1000; and .dockerignore has to exclude it, because COPY . . would otherwise bake
a builder's checked-out module into every image — and Docker seeds a fresh named
volume from image contents, so it could have surfaced on a deployment that never
installed it.

MODULE_API §4.1 gains the concrete Compose values and states outright that a
missing modules directory is not an error, which the loader has always done and
the contract never said.

Website side: RunicGateway/website#136.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 00:50:17 -05:00
parent d66ee832c5
commit 8a7b099c2d
2 changed files with 63 additions and 5 deletions

View File

@@ -703,7 +703,13 @@ half needs rethinking before Phase 2 builds on it.
`app.js` scans `modules/*/module.json` with `fs.readdirSync` at require time and mounts what it
finds (`MODULE_SYSTEM.md` §1.12). The database is not consulted. `MODULES_DIR` defaults to
`<repo>/modules` and is overridable by env for tests and for the Docker volume mount.
`<repo>/modules` and is overridable by env for tests and for the Docker mount. Under Compose it is
set to `/app/modules`, where `./modules` is bind-mounted read-write (`MODULE_SYSTEM.md` §2.5); the
image itself carries that directory empty and owned by the container user, and `.dockerignore`
excludes any local one so a module can never be baked in.
**A missing modules directory is not an error.** The scan catches and returns, because "no modules
installed" is the normal state of bare core and the loader must not make the mount mandatory to boot.
**The trigger is one explicit call, and there is no lazy self-scan** (§7.6). `app.js` calls

View File

@@ -428,8 +428,12 @@ leaves the row `disabled`, which the reset never touches.
### 2.5 Install, uninstall, purge
Modules live on a **mounted volume**, not in the image — the same treatment `uploads` already gets in
`docker-compose.yml`. That is what makes the WordPress model work against a pull-only image.
Modules live on a **mounted volume**, not in the image. That is what makes the WordPress model work
against a pull-only image. *(Settled in Phase 2 PR 9: it is a **bind mount** of `./modules`, not the
named volume this sentence originally reached for by analogy with `uploads` — hand-placing a module
directory is a supported install below, and a named volume would route it through `docker cp`. The
image's copy is excluded by `.dockerignore`, so a module in a builder's working tree can never ship
inside an image; see `modules/README.md` in the website repo.)*
**Install:** admin selects the module → bundle downloaded from the module repo's release and verified
against its `sha256` → unpacked into `modules/<id>/` on the volume → `installed_modules` row written →
@@ -511,14 +515,14 @@ too (API §7.2).
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, the chunk's static
mount 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.
9. `docker-compose.yml` gains the `modules` mount.
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-7 done.**
**Progress: complete — all nine PRs landed.**
- **PR 1** — `installed_modules` and the state machine, with the stored shape and the boot rules
settled in §2.4 above.
@@ -678,6 +682,54 @@ fixed enum because the leg set is whatever has been registered.
admin can relabel a module row in Admin → Navigation and have it persist and apply — the whole
point of merging before the override layer. Zero CSP reports, zero console errors.
- **PR 9** — the mount itself, which closes the phase: `docker-compose.yml` gains `./modules` at
`/app/modules`, the `Dockerfile` creates that directory node-owned, `.dockerignore` keeps any local
one out of the image and `modules/README.md` documents the directory for whoever opens it.
It is a **bind mount, not the named volume** §2.5 assumed by analogy with `uploads`. Placing a
module directory by hand is a supported install, and a named volume makes that a `docker cp` into a
running container — the one install path an operator without the admin panel has, routed through
the least discoverable mechanism Docker offers. A bind mount makes it `tar -xf … -C ./modules`, and
makes the installed set something an operator can *see*. §2.5 is amended above; nothing else about
install, uninstall or purge changes.
Two consequences worth stating, because both are silent failures rather than errors. The directory
is **tracked** — via its README, the same shape `brand/` already uses — because Docker recreates a
missing bind-mount source as `root:root`, and the container runs as uid 1000: delete `modules/` from
a checkout and the next install fails on a permission error that names no cause. And the mount is
**read-write**, since §2.5's install unpacks into it from inside the container; deferring that to
Phase 4 would have bought nothing, as a mount-mode change is a redeploy either way.
`.dockerignore` matters more than it looks. `COPY . .` would otherwise bake whatever module the
builder had checked out into every image — and because Docker seeds a *fresh* named volume from
the image's contents, that module could have appeared on a production deployment that never
installed it. The exclusion is what makes "modules live on a mount, never in the image" true rather
than merely intended.
Verified against a **running container**, which is the only thing that can check any of the above —
a compose file that parses proves nothing about ownership, and nothing about what the image
contains. The image carries an empty, node-owned `/app/modules` despite a module sitting in the
build context. A module on the mount loads, mounts, runs `onBoot` and reaches `started`;
`/api/v1/public/modules` lists it; its chunk serves from the entry's directory alone, with the
module's own server source and `module.json` both `404`. The [§7.7](MODULE_API.md#77-the-client-half-has-to-be-verified-in-a-browser--the-timing-bug-no-test-could-see)
browser smoke was re-run against the containerised stack rather than a working tree: in Chrome, the
page renders on first paint inside core's `PublicLayout`, drawing React and the UI kit off
`window.__rg`, with its nav row interleaved into core's public nav — under the enforced
`script-src 'self'`, with zero CSP reports and no console errors. Removing the directory by hand and
restarting reconciles the row to `startup_failed`/`require` exactly as §2.4 says, and leaves core
healthy with no script injected and `{"modules":[]}` published.
**933 server tests** and **160 client tests** pass, both unchanged — this PR ships no application
code. `routes.manifest.json` stays at 230 routes and the OpenAPI spec regenerates byte-identical.
The one source change is a comment: `scripts/routeManifest.js` enumerated the filesystem-conditional
mounts it excludes and had never been told about `/modules`. Its filter is an allowlist, so the
behaviour was always right and only the explanation was stale.
**Phase 2 is complete.** Core can discover, validate, mount, migrate, boot, publish, serve and
navigate a module it does not contain, on a deployment that builds nothing — and it does all of that
while no existing URL has moved. The exit criterion held: `routes.manifest.json` went 229 → 230 across
the whole phase, and the one added line is PR 6's deliberate `GET /api/v1/public/modules`.
**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