feat(modules): mount the modules directory as a volume (phase 2, PR 9)
Closes Phase 2. Modules live on a mount, never in the image — that is what lets an operator add one to a pull-only deployment without building anything. `./modules` is a bind mount rather than a named volume: placing a module directory by hand is a supported install (MODULE_SYSTEM.md §2.5), and that has to be doable from the host rather than through `docker cp`. Read-write, because the admin panel's install/uninstall unpacks and removes directories there. The directory is tracked via its README so it exists in the checkout with the operator's own ownership — Docker recreates a missing bind-mount source as root:root, which the container user could not then write. `.dockerignore` excludes it so a module in the builder's working tree can never ship inside an image. Also corrects the route-manifest generator's list of filesystem-conditional mounts, which never picked up `/modules` when PR 7 added it. Comment only; the generator filters on an allowlist, so its behaviour was already right. Verified against a real container, not just a parsed compose file: image carries an empty node-owned /app/modules despite a module in the build context; a module on the bind mount loads, mounts, replays and reaches `started`; `/api/v1/public/modules` lists it; the chunk serves from the entry's directory only (server source and module.json 404) with `no-cache`; the injected tag follows core's bundle; and in Chrome the page renders on first paint inside core's PublicLayout with its nav row interleaved into core's public nav, under enforced `script-src 'self'` with zero CSP reports and no console errors. Removing the directory by hand reconciles the row to `startup_failed`/`require` and leaves core healthy with no injection. 933 server + 160 client tests pass, manifest unchanged at 230 routes, swagger regenerates byte-identical. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
75
modules/README.md
Normal file
75
modules/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Installed modules
|
||||
|
||||
This directory is bind-mounted into the container at `/app/modules` (see
|
||||
`docker-compose.yml`). It is where **installed modules** live — the game-specific
|
||||
routes, tables, screens and nav that are not part of core. Design of record:
|
||||
[`docs/website/MODULE_SYSTEM.md`](../../docs/website/MODULE_SYSTEM.md); the
|
||||
normative contract module authors build against is
|
||||
[`docs/website/MODULE_API.md`](../../docs/website/MODULE_API.md).
|
||||
|
||||
**Core ships no module.** This directory is empty in a fresh checkout, and the
|
||||
site runs cleanly that way — an empty `modules/` is the normal state for bare
|
||||
core, not a misconfiguration. Everything below is ignored by git except this
|
||||
README, which exists so the directory itself is tracked: `docker-compose.yml`
|
||||
bind-mounts it, and Docker recreates a *missing* bind-mount source as a
|
||||
root-owned directory the container user cannot write.
|
||||
|
||||
## Layout
|
||||
|
||||
One directory per module, named for its id, each holding a prebuilt bundle:
|
||||
|
||||
```
|
||||
modules/
|
||||
uo/
|
||||
module.json # the manifest the loader reads
|
||||
server/index.js # registers routes, streams, hooks
|
||||
server/db/schema.sql # tables, replayed every boot
|
||||
server/db/purge.sql # only ever run by an explicit purge
|
||||
client/dist/entry.js # prebuilt ESM chunk, served at /modules/uo/
|
||||
```
|
||||
|
||||
**Nothing here is compiled by the operator.** A module arrives already built —
|
||||
that is the whole point of the design. There is no install step that runs a
|
||||
bundler, and none that needs one.
|
||||
|
||||
## Installing a module
|
||||
|
||||
Two supported paths, both writing the same `installed_modules` row:
|
||||
|
||||
- **The admin panel** downloads the bundle from the module's release, verifies it
|
||||
against its `sha256`, and unpacks it here.
|
||||
- **By hand**, for a compose-managed host: unpack the bundle into a directory
|
||||
named for the module id, e.g. `tar -xf uo-1.0.0.tgz -C ./modules`.
|
||||
|
||||
Either way, **adding or removing a module takes a restart.** The loader scans
|
||||
this directory synchronously at startup (`MODULE_API.md` §4.1); nothing placed
|
||||
here is picked up by a running server.
|
||||
|
||||
```
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
On boot each module is validated, mounted, its schema fragment replayed and its
|
||||
`onBoot` hook run — reaching `started`, or `startup_failed` with the stage and
|
||||
reason recorded. A module that fails to start does not stop the site: core, and
|
||||
every other module, carry on without it.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Removing a directory and restarting is enough to stop a module serving. Note that
|
||||
this is *not* the same as an uninstall through the admin panel, which also marks
|
||||
the row `disabled` — a directory that simply vanishes leaves a row claiming to be
|
||||
enabled, which the loader records as `startup_failed`.
|
||||
|
||||
A module's **tables and data are retained** in both cases. Dropping them is a
|
||||
separate, explicit, destructive purge; it is never bundled into an uninstall.
|
||||
|
||||
## Ownership
|
||||
|
||||
The container runs as uid 1000 (`node`) and the admin panel writes here, so the
|
||||
app must be able to write this directory. It is created by your checkout, with
|
||||
your ownership. If they differ:
|
||||
|
||||
```
|
||||
chown -R 1000:1000 modules
|
||||
```
|
||||
Reference in New Issue
Block a user