Files
website/modules/README.md
wtclaude a103e0ce10
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 1m39s
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>
2026-08-11 00:48:48 -05:00

3.0 KiB

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; the normative contract module authors build against is 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