The whole server half is out: 40 files, ~9,674 lines, 27 of 68 tables. The
acceptance criterion held exactly -- core's manifest goes 228 to 158 public
routes and the 70 that left reappear byte-identical once the module loads, with
routes.guards identical across all 228.
The contract grew to 1.1.0: ctx.activity.log, ctx.users.getById,
ctx.site.baseUrl, ctx.middleware.rateLimit + accountChangeLimiter, and a fourth
registry, registerPostHook. Each is documented with why it could not be
vendored, because that reasoning is the useful part -- an admin action a module
performs belongs in core's ONE audit log, a second rate-limit store is a limit
enforced by two counters, and core's CMS was calling a UO file directly.
§2.7.1 gains the slice record: the core.js port mechanism and its consequence
(require order is load-bearing), the vendoring line (pure leaf helpers may be
copied, security controls may not), the two core defects the extraction exposed,
the one deliberate behaviour change, and the one test that looked like it should
move and should not.
Co-Authored-By: Claude <noreply@anthropic.com>
Found before writing any of it. §2.7.1 split the server extraction five ways by
feature; it does not divide, because two contract rules compose.
A mount prefix is claimed whole -- ownedByCore probes the live tier router and
registerRoutes validates single-segment prefixes -- and /admin/shard is one
386-line router carrying 25 routes across atlas, clilocs, shard-ops, visibility,
market and account links. Meanwhile a model cannot be shared across the boundary
(§5.1), so it moves with the last route that consumes it. Take the closure and
every prefix is in it: /public/atlas holds shardAtlas with /admin/shard, which
holds shardState/shardEvents/shardMarket with /public/shard, which holds
uoLinkConfig with /admin/uo-link, and shardClilocs/shardLinks with /player/shard.
Landing any one of the old slices alone would either strand core importing
modules/uo/ -- what acceptance criterion 2 forbids -- or delete routes core is
still serving.
Giving the admin routes their own prefixes would divide it and is rejected: it
changes API URLs, which §1.2 promises not to do, and not hypothetically. The
shipped Android app calls POST /api/v1/admin/shard/kick, /ban, /unban,
/broadcast and the three /pages routes.
So the table is now six slices: 0 the skeleton (done), 1 the whole server half
as ONE PR per repo structured as five commits along the old slice lines, 2-4 the
client halves, 5 close the phase. The client half is unaffected and still slices
cleanly -- the registry takes routes per area, with no prefix atomicity and no
shared models, the same asymmetry that let the two halves be separated at all.
Co-Authored-By: Claude <noreply@anthropic.com>
Slice 0 built the module bundle skeleton against the contract and found that
§3.6 does not work as written. It shows Rollup's `external` alongside the
resolve aliases, and the two do not compose: Rollup asks `external` BEFORE
Vite's alias resolver runs, so a specifier in both is marked external and never
aliased. The chunk then emits bare `import "react"`, which no browser can
resolve without an import map, and CSP forbids the inline script an import map
has to be. It built cleanly and emitted exactly that.
§3.6 is corrected: alias only, `external` empty, with the alias table shown in
full because the anchoring is what stops `react` also capturing
`react/jsx-runtime`. What `external` was guarding -- a missed alias welding a
second React into the chunk -- moves to a resolution-time build plugin, and two
properties of that plugin are now contract because both were wrong first: it
hooks `transform` rather than `load` (first-wins, so it never ran), and its
forbidden-package list is stated rather than derived from the alias list
(deriving it means deleting an alias also deletes the guard).
Also records slice 0's outcome in §2.7.1, including the finding that generalises
past this repo: the boundary check failed on its own documentation, because the
comments describing what it catches are written in the syntax it catches. Slice
8's §5.2 grep has the same problem waiting for it. And the loader skips a
SYMLINKED module directory silently, which is the first thing to check when a
module fails to appear locally.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2 is closed, so Phase 3 needs a plan before any of it is extracted.
Four decisions, and one finding that set the first of them.
The finding: because API URLs are preserved (§1.2), a feature's server and
client halves are independent. Core's client keeps calling
/api/v1/public/shard/status after the module serves it, and a module page
calls the same URL while core still does. Nothing forces vertical slices, so
the extraction is server-first then client, sliced by feature, ten slices.
Merge order within a slice is module-uo first, then website. The loader's
ownedByCore probe stops a module LOADING while core owns its prefix, but the
module's own CI never loads it into core, so its PR merges fine beforehand --
and edge then serves the feature from core right up to the moment core drops
it, with no window where the branch is missing it outright.
Criterion 1's grep reads code, not prose: filenames, import specifiers, route
path literals and declared identifiers. Core's marketing copy legitimately
says "shard" in a dozen places and a literal word grep would have failed CI on
each while proving nothing about the boundary. That copy is rewritten in its
own slice instead, which is real work with a real review rather than an
exemption hidden in a pattern.
module-uo's CI clones core at a pinned ref to freeze its route manifest --
nothing else proves the URLs it claims are the URLs it serves -- and the
module-rust dry run lands in docs/modules/ where §2.10 already aggregates
module documentation.
Also records the measured surface (72 server files, 51 client, 32 test files),
which supersedes the Phase 0 estimate, and the one kit gap: lib/format.js is
vendored by the module rather than becoming an eighth §3.4 member.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
Records Phase 2 PR 8 of the module system: MODULE_SYSTEM.md gains the PR
entry in 2.7 and the built-it notes on 1.4 and 1.5; MODULE_API.md 3.3 is
amended where building it settled something the draft left open or got wrong.
The amendments to 3.3:
- The pipeline arrow had role/feature filtering BEFORE admin overrides. The
code has always been the other way round, deliberately - the filter runs
last so it stays a boundary an override cannot cross (THEMING_AND_NAV 7).
- `feature` was documented as public-area-only. It applies in all three
areas: core's admin and player navs still carry no flags, but a module row
that declares a gate and has it silently ignored is a trap.
- How a provider is found: by the module that registered the row, not by a
prefix parsed out of the flag name. Core's own rows resolve against the
owner id `core`, which core registers useShardFlags under.
- What a provider hook returns, and that every unknown fails OPEN.
- Why calling one hook per provider in a loop is legal, and why the
enumerator is a module export rather than a member of registry.
- Six details the interleave settled: unordered rows append rather than
defaulting to 0; an ungrouped admin row gets its own trailing group rather
than joining core's; a module-created group is a legal override
destination; a colliding `to` is dropped with a warning; and why the
interleave must precede the override merge.
MOD_PATHS' replacement is recorded in both files, including the defect the
derivation fixed: the moderator redirect was a third hardcoded list that
disagreed with MOD_PATHS about /admin/houses. THEMING_AND_NAV 7's
"moderator confinement" note is amended to match.
Code: website PR 8 (client-only; 160 client tests, manifest and OpenAPI
unchanged), verified with the 7.7 browser smoke.
Co-Authored-By: Claude <noreply@anthropic.com>
MODULE_API.md
- 3.1 gains the four normative constraints PR 7 settled: the static root is
the entry's directory (an entry in the module root is rejected), the mount
sits behind the module's state guard with no-cache, the script tag is
injected before </body> so core's bundle runs first, and core renders on
DOMContentLoaded with a readyState === 'complete' check
- 2.1: client.entry must be in a subdirectory; present-but-empty is rejected
- 3.4: AdminPage struck from the UI kit — core has no such component
- 7.7 (new): the browser smoke, and the timing bug no test in this repo can
see. Every test passed against a build that did not work in a browser
MODULE_SYSTEM.md
- 2.6 step 3 amended to the </body> injection point
- 2.7 records PR 7's four decisions and what verified them; 1-7 of 9 done
BACKEND_DESIGN.md
- /modules is the fourth filesystem-conditional static mount outside the
route manifest, with its root, guard and cache policy stated
Co-Authored-By: Claude <noreply@anthropic.com>
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>
Records phase 2 PR 5 of the module system: the lifecycle hooks a module
registers, how they are dispatched, and what a boot does to installed_modules.
MODULE_API.md 2.5 gains the reconcile's four steps in order, the rules that fall
out of them (the operator's `disabled` wins over any outcome; a bookkeeping
failure is not a boot failure; a module with no onBoot still reaches `started`;
a module whose onBoot threw gets no onShutdown), and why onBoot has no timeout
while onShutdown has a five-second budget -- shutdown races the process being
killed and boot does not.
4.4 gains the failure_stage table: every failure is recorded against the 4.3
step that produced it, so the admin panel can say where a module broke and not
only what the message was.
MODULE_SYSTEM.md 2.4 records the new rule for a row whose directory is gone, and
2.7 the PR 5 progress entry with its four decisions. BACKEND_DESIGN.md's
installed_modules section gains the write path now that one exists.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2 PR 4 of MODULE_SYSTEM.md §2.7. Records what §1.8's three entangled files
and §1.9's extension slot actually became, and four decisions taken with them.
MODULE_API.md §2.4:
- registerNotificationStreams takes the catalog ALONE. `mapEvent` was a leftover
from before §1.8's push inversion was settled — a module owns fromShardEvent
and calls publish() with an id it resolved, so core never needs a second route
to the same place. It follows that the public-safety filter is module-internal,
which is the right home: the kinds, the streams and the filter become one file
that moves together.
- the entry shape is two booleans, not a single `scope` — that object is the body
of GET /auth/me/notifications/streams and a shipped Android client reads both.
- registerAnnounceLeg gains `label`, so a module's leg renders in the admin panel
with no client change; and legs became `announce_job_legs` ROWS, because a
module cannot ALTER a core table and a registered leg had nowhere to live.
- every call STAGES; nothing commits until the module as a whole is known good.
New §6.5 — the eight grandfathered names (seven stream ids, one leg id), allowed
to `uo` alone by explicit allowlist, the same shape as the loader's legacy table
prefixes. Grandfathering rather than dropping the rule is what keeps it real for
every module written after this one.
New §6.6 — an extension slot is invisible to static analysis, so core needs §6.1's
fragment merge for its OWN slot fills, a phase earlier than the plan expected.
Moving the six users-detail routes behind the slot deleted 407 lines from
swagger-output.json while printing `Success`. Which slots and where each hangs are
both derived — from the registry and from the live express stack — because a
written-down copy drifts.
MODULE_SYSTEM.md §1.8 / §1.9 record the outcome per file, and §2.7 the progress.
BACKEND_DESIGN.md and website-README.md follow the moved files; the PUBLIC_KINDS
reference at §"visibility" named the wrong file and is corrected to
utils/shardBroadcast.js.
Co-Authored-By: Claude <noreply@anthropic.com>
Docs half of website PR 3 (phase 2). MODULE_SYSTEM.md 2.7 gains the PR 3 entry;
MODULE_API.md 2.6 gains the decisions the section did not previously answer.
The one that shapes the code: a fragment is VALIDATED at load time and EXECUTED
later, split on whether a database is needed to know the answer. Everything 2.6
states about the SQL is knowable by reading the file, so breaking one of those
rules costs a module its mount entirely rather than mounting and 503ing with its
tables half created. What is left for the replay is the class of failure only
the server can report, and that is post-mount and answers 503.
Also recorded: the rules are enforced as a leading-verb allowlist (CREATE,
ALTER, INSERT, UPDATE -- the four core's own schema.sql uses) rather than the
DROP denylist 2.6 words them as, because the file is replayed on every boot and
a denylist only ever bans what somebody thought of; the replay sits outside
ensureSchema's wait-for-the-database retry loop, so one module's bad SQL cannot
cost the boot two minutes; partial application is accepted, since MariaDB
self-commits DDL and no transaction could undo it; and `npm run seed` is the one
sanctioned caller that replays nothing, because it never requires app.js and so
has no scan to read.
Co-Authored-By: Claude <noreply@anthropic.com>
Records what phase 2 PR 2 decided against the two things MODULE_API.md
left open for it.
7.6 is settled as an explicit modules.load(tierRouters) call in app.js
rather than a require-time scan: the loader needs the tier routers handed
to it for the 4.3 check, which a require-time side effect cannot receive,
and a require's position enforces an ordering constraint invisibly.
4.1 and 4.3 gain the mechanics that fall out -- where the call must sit
in app.js and why in both directions, that mounting is a second pass
after validation, and that core's prefix ownership is probed on the live
tier routers with express's layer.match() rather than declared in a table
that was already stale in the spike.
2.7 records PR 2 as done.
Co-Authored-By: Claude <noreply@anthropic.com>
RunicGateway/Integration-kit -- empty today, exactly where Module-uo was at the
start of Phase 0 -- becomes the book someone reads to build a module for a game
that is not UO, starting from nothing. It is the only document in the project
written for an audience outside the org.
One book rather than a page in each repo, because the job spans all three layers
of the data path: the website module (the bulk of it), the sidecar and WHY it
exists -- the shard is never network-reachable, it dials out, the wire is a
versioned contract -- and the game-side plugin that feeds it without ever letting
a wedged sidecar stall the game.
The rule that keeps it from rotting: it never re-specifies a contract. MODULE_API.md
stays normative for the module surface and link/PLAN.md + INTEGRATION.md for the
wire protocol; the kit teaches and links out for the authority. A guide that
restates a contract diverges from it silently, and the reader who follows the
divergent copy gets a module that fails validation for reasons the guide cannot
explain.
It trails the implementation rather than leading it -- scaffolded when Phase 2
lands, written against Phase 3's extraction with Module-uo as the worked example,
finished alongside Phase 4 -- and it is a draft until someone builds a working
module for a second game from it alone, without reading core's source.
Adds 2.11, Phase 5 in 2.7, and settled decision 13.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2 PR 1 of the module system records two things 2.4 left open: the
states are stored in one `state` column rather than a policy flag beside a
runtime one, and every boot recomputes the outcome states while leaving
`disabled` alone.
That second rule is the one with consequences worth writing down -- a
startup_failed module is retried on every restart, so an operator who fixes
the cause needs no admin-panel visit; a running module can never display a
stale failure reason; and disabling, the one operator decision rather than
outcome, survives restarts. Also states what the row does NOT decide: the
loader scans the filesystem before the database is reachable, so the URL
surface is a property of the volume, which is what keeps
routes.manifest.json generatable against a dead database.
BACKEND_DESIGN.md 3 gains the installed_modules columns alongside the other
tables.
Co-Authored-By: Claude <noreply@anthropic.com>
Part 7 of MODULE_API.md: the three exit criteria and their results, the two
contract changes the spike forced (ctx.express/ctx.validator and
window.__rg.jsxRuntime), the empirical confirmation of the OpenAPI split, the
loader's tested failure guarantees, and the three artifacts in the branch that
are consequences of stopping at six routes rather than intended shape.
Phase 1 is complete: docs/website/MODULE_API.md is written and the spike met
every exit criterion on website branch spike/module-atlas (cut from edge, never
merged).
Co-Authored-By: Claude <noreply@anthropic.com>
Both approved by the org lead 2026-08-10. §6.1 moves from open question to
decision A with the per-side obligations spelled out in a new §6.1a: modules
ship a swagger-fragment.json with fully-qualified paths and namespaced schema
keys, core merges started modules' fragments into /api/docs.json at request
time and always wins a key collision, and swagger-output.json stays exactly
what core's own routes generate.
Co-Authored-By: Claude <noreply@anthropic.com>
The normative contract between core and an installed module: the `ctx` handed
to a module's entry point, the `register*` calls, the client-side registry and
shared-dependency global, the schema-fragment rules, and the loader's
validation and failure obligations. Every member is derived from what the UO
code actually imports today, re-read against the working tree.
Part 6 records four places the survey contradicted MODULE_SYSTEM.md:
• OpenAPI generation is STATIC analysis (swagger-autogen parses app.js as
text), unlike routeManifest.js which walks the live Express stack. A
filesystem-scanning loader is invisible to it, so module routes would be
silently absent from swagger-output.json. Three options, one recommended;
needs a decision before Phase 2.
• The client contract is much larger than §2.1 implies — the atlas pages
import five core modules beyond React, so the plan needs a curated UI kit
and a request primitive on window.__rg.
• shardVisibility is module-owned and the atlas depends on it, so the spike
carries it; two copies coexist during the spike by design.
• Two counts corrected: 27 UO tables (not 25), 6 atlas routes (not 5).
MODULE_SYSTEM.md gains a pointer to the contract and the corrected table count.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 03:44:57 -05:00
7 changed files with 1884 additions and 44 deletions
| [MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) | Making the site game-agnostic: game logic becomes an installable module — design of record |
| [MODULE_API.md](website/MODULE_API.md) | The module ↔ core contract: `ctx`, the `register*` calls, the client registry and the loader's obligations |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
| `failure_stage`, `failure_reason` | the stage a failure happened at (`manifest`, `core_api`, `mounts`, `extensions`, `schema`, `require`, `register`, `boot`) and its recorded reason |
| `source`, `sha256` | the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand |
| `installed_at`, `started_at`, `updated_at` | `started_at` is the last **successful** start |
**This table never decides which routes exist.** The module loader scans the filesystem at require
time, before the database is reachable, so the URL surface is a property of the volume — which is what
lets `routes.manifest.json` be generated against a dead database. A disabled module stays mounted and
is guarded; the row decides whether it *answers*, not whether it is there.
**Every boot resets each non-disabled row to `enabled`** and clears its recorded failure, then the load
writes that boot's outcome. So a `startup_failed` module is retried on the next restart (an operator
who fixes the cause needs no admin-panel visit), a running module can never display a stale reason,
and `disabled` — the one operator *decision* rather than outcome — survives untouched. A re-install or
upgrade refreshes the metadata and leaves `state` alone.
The write happens in one place, `src/modules/lifecycle.js`, on the boot path after `ensureSchema()`
and before the listener binds: it resets the last boot's outcomes, writes a row for every module found
on the volume (with NULL provenance for a hand-placed directory), marks any row whose directory is
**gone**`startup_failed`, and then runs each surviving module's `onBoot` and records what happened. A
`disabled` row is guarded, not booted, and never has its failure re-recorded — an outcome must not
overwrite the operator's decision. Every one of those writes is individually caught: a row that will
not update is worse reporting, never a failed boot.
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
[`MODULE_API.md`](MODULE_API.md) Part 4.
---
## 4. API contract
@@ -679,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
@@ -693,9 +735,16 @@ slashes from generated path keys — see *Regenerating the spec* in the website
domain split makes that necessary.
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the
internal listener. The SPA catch-all, `/uploads` and`/brand` are filesystem-conditional static
mounts — not API contract, and including them would make the output depend on whether CI had built
the client.
internal listener. The SPA catch-all, `/uploads`,`/brand` and `/modules` are filesystem-conditional
static mounts — not API contract, and including them would make the output depend on whether CI had
built the client, or on which modules happened to be on the volume of the machine that generated it.
`/modules/<id>/` is the last of those and the newest: an installed module's prebuilt client chunk,
served from the directory its `client.entry` sits in and never from the module root, behind the
module's own state guard (`503` when it failed to start, `404` when disabled) and with
`Cache-Control: no-cache`, because Vite's library build emits an unhashed `entry.js`. Anything else
under `/modules` is a `404` rather than the SPA shell. The full contract is
[`MODULE_API.md`](MODULE_API.md) §3.1.
A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route,
the middleware handler count plus the *named* middleware on its mount chain. It exists because a
@@ -858,6 +911,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) |
@@ -1102,7 +1156,7 @@ it picks, it fails open on one side.)
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
streams**. `PUBLIC_KINDS` still exists and is still exported (`notificationStreams.js`) but is now
streams**. `PUBLIC_KINDS` still exists and is still exported (`utils/shardBroadcast.js`) but is now
**derived** from the kind map rather than hand-maintained, so the two cannot drift.
**`PUBLIC_KINDS` is a module-load constant and must not be used to answer "may this caller read this
`src/utils/newsGump.js` is module-side (news → in-game gump) and moves whole.
**Done in Phase 2 PR 4**, with core still the only registrant — the registries are
`src/modules/registries.js` and core goes through them by the same door a module will
(`registerCore()`, called explicitly from `app.js` before `modules.load()`). What each of the three
became:
1. Split in two. `config/coreStreams.js` is core's one stream (`news.post`, produced by the website's
own posts path); `config/shardStreams.js` is the other seven plus `mapShardEvent` and the
public-safety filter, and moves to module-uo whole. `registerNotificationStreams` lost its
`mapEvent` half — see [`MODULE_API.md`](MODULE_API.md) §2.4 for why that was a leftover, and what
follows for the public/personal split.
2. Inverted. `pushDispatch.js` is `publish` + `isAllowedEndpoint` and nothing else;
`utils/shardPush.js` holds `fromShardEvent` and is what `shardIngest` now calls.
3. Legs became registrations, and per-leg **rows**. The `towncrier_*` / `discord_*` column groups on
`announce_jobs` could never have held a module's leg — a module cannot `ALTER` a core table — so
they became `announce_job_legs`, backfilled and dropped in the same idempotent replay. The worker
no longer contains the word "towncrier": it iterates whatever is registered.
The residue in core is a one-time backfill block in `schema.sql`, deletable once every deployment has
booted it, and the two lines of `registerCore()` that Phase 3 turns into module-uo's `register()`.
### 1.9 A fourth mount shape: module routes under a core resource
`router/v1/admin/users.router.js` mounts `usersShard.controller.js` at six UO sub-paths of a **core**
@@ -169,6 +210,17 @@ narrow **extension slot** on `/admin/users/:id` that the module mounts into, so
what "shard" means and all six URLs are preserved. Only core may declare an extension slot; a module
may not invent one.
**Both done in Phase 2 PR 4**, with core filling its own slot: the six paths are
`router/v1/admin/usersShard.router.js`, registered into `admin.users.detail` by `registerCore()`, and
Phase 3 changes the registrant rather than the routes. The slot's router is created at declare time
and filled later, because `users.router.js` is required while `app.js` is still being built. It is
mounted **last** on the resource, so core wins any path conflict by first-match.
One consequence was not foreseen and is worth the warning: **a slot is invisible to static analysis.**
There is no literal mount for `swagger-autogen` to follow, so the move silently deleted all six paths
from `swagger-output.json` while printing `Success`. The OpenAPI build now merges a generated
fragment per filled slot — [`MODULE_API.md`](MODULE_API.md) §6.6.
### 1.10 The Discord bot has no UO logic
The draft listed the bot's "UO-specific event/moderation logic" as an extraction candidate. Grepping
@@ -229,8 +281,9 @@ It also rules out import maps as the shared-dependency mechanism: `config/csp.js
**Resolved** — see §2.6. The path that survives all three constraints is: the module's CI ships a
**prebuilt ESM chunk**, core hands it React through a **global** rather than an import map, and
`htmlShell.js:111` injects a **same-origin**`<script type="module" src>`, which `'self'` already
allows.
`htmlShell.js` injects a **same-origin**`<script type="module" src>`, which `'self'` already
allows. Verified in a browser against the enforced policy in Phase 2 PR 7, not only reasoned about
([`MODULE_API.md`](MODULE_API.md) §7.7).
---
@@ -331,10 +384,56 @@ marks that one module `startup_failed`, records the reason, and the site comes u
routes and nav absent. `startup_failed` is recoverable from the admin panel — disable, retry, or roll
back to the previous version — with no shell access to the box.
**Where the states live.** One `installed_modules` row per module, keyed by its id, with the machine
held in a single `state` column carrying all five values — the shape this section already describes,
rather than a policy flag beside a runtime one. The table also carries `name`/`version` for the admin
screen, `failure_stage` + `failure_reason` for [`MODULE_API.md`](MODULE_API.md) §4.4's recorded
reason, `source` + `sha256` for the
install provenance of §2.5 below (both null for a directory placed on the volume by hand, which stays
supported), and `installed_at` / `started_at` / `updated_at`. Full column list in
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) §3.
**The row is a record of what happened, never the source of truth for what is mounted.** The loader
scans the filesystem at require time, before the database is reachable (API §4.1), so the URL surface
is a property of the volume and not of a row here. What the row decides is whether a mounted module
*answers* (`disabled` ⇒ its guard 404s, API §4.5) and what the admin panel shows after a failure.
This is also why
`routes.manifest.json` can be generated against a dead database.
**`disabled` is the only state a boot leaves alone.** Every boot resets each non-disabled row to
`enabled`, clearing any recorded failure, and the load that follows writes this boot's outcome —
`started` or `startup_failed`. Three consequences, all deliberate:
- **A `startup_failed` module is retried on every restart.** An operator who fixes the underlying
cause — a truncated file, a missing dependency, a database that was not up yet — gets the module
back by restarting, with no admin-panel visit. The cost is that a deterministically broken module
re-records its failure each boot, which is the honest thing for it to do.
- **A stale reason can never be shown against a running module**, because every non-failing
transition clears the failure columns.
- **Disabling is an operator decision, not an outcome**, so it survives restarts untouched — and a
module the operator switched off is neither started nor re-recorded as failed if it happens to be
broken. `installed` is likewise transient: it is the gap between an install writing the row and the
restart that resolves it.
A re-install or an upgrade refreshes `name`/`version`/provenance and deliberately leaves `state`
alone: upgrading an enabled module must not silently switch it off, and re-installing a disabled one
must not silently switch it on.
**A row whose directory is gone is marked `startup_failed`** (stage `require`, reason "module
directory not present on the volume"), settled with PR 5. The boot reset above has just moved it to
`enabled`, and a row claiming to be enabled for a module that is not on the volume is the one state
that is simply untrue — it would be read that way by the admin panel and by
`GET /api/v1/public/modules` alike. This catches only a directory deleted by hand: an uninstall
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 →
@@ -362,12 +461,20 @@ builds nothing, production pulls a prebuilt image, and `script-src 'self'` forbi
2.**Core exposes the shared dependencies on a global** before mount — `window.__rg = { react,
reactDom, router, registry }` — and the module's externals resolve to it. A global, not an import
map, precisely because an import map must be inline and CSP forbids that.
3. **`htmlShell.js` injects the module's entry script.** It already rewrites `</head>`
(`utils/htmlShell.js:111`), so this is an extension of a working mechanism, not a new one. The tag
is `<scripttype="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`.
3. **`htmlShell.js` injects the module's entry script.** It already rewrites the shell it serves, so
this is an extension of a working mechanism, not a new one. The tag is
`<scripttype="module"src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
nonce and no inline. *(Amended in Phase 2 PR 7: the tag is injected before `</body>`, not at the
`</head>` rewrite this step assumed. Module scripts execute in document order and core's bundle
has to run first, so the injection must be after core's own script tag wherever a bundler chooses
to put it — [`MODULE_API.md`](MODULE_API.md) §3.1, which also states the static mount's root, its
state guard and its cache policy.)*
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).
@@ -378,11 +485,20 @@ 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`. Then a throwaway spike on an unmerged branch moving
**`/api/v1/public/atlas/*`** behind the proposed surface — the smallest honest test: five routes,
DB-backed, no sidecar, no SSE, one boot hook. The spike must *also* prove the §2.6 chunk load end to
end, since that is the highest-risk decision in the plan. Exit criteria: no internal-file imports,
`npm run routes:manifest` produces a zero-line diff, and the chunk loads under the enforced CSP.
[`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
hook. The spike must *also* prove the §2.6 chunk load end to end, since that is the highest-risk
decision in the plan. Exit criteria: no internal-file imports, `npm run routes:manifest` produces a
zero-line diff, and the chunk loads under the enforced CSP.
**Phase 1 is complete.** The spike ran on `website` branch `spike/module-atlas` (cut from `edge`,
never merged) and **met all three exit criteria** — see [`MODULE_API.md`](MODULE_API.md) Part 7. §2.6
survives intact: the prebuilt chunk loads and renders under `script-src 'self'` with zero violation
reports. The one thing it changed is that §2.6's one-React rule turns out to have a server-side twin
nobody had written down — a module cannot resolve core's `express` either, so core hands that over
too (API §7.2).
**Phase 2 — Core scaffolding, no behaviour change.** One PR each, in order:
@@ -392,16 +508,227 @@ end, since that is the highest-risk decision in the plan. Exit criteria: no inte
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.
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, and the
`htmlShell` script injection — empty registry, no visible change.
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, 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: `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: 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.
- **PR 2** — `server/src/modules/loader.js`: the filesystem scan, manifest validation, prefix and
table-name collision rejection, per-module try/catch and the tier mount, behind the §4.5 dispatch
guard. Two decisions landed with it, both recorded in [`MODULE_API.md`](MODULE_API.md): the load
trigger is **one explicit `modules.load(tierRouters)` call in `app.js`**, never a lazy scan
(API §7.6); and the "does core own this prefix" check **probes the live tier routers** rather than
a hardcoded table, so it cannot drift when core adds a capability router (API §4.3). The three
de-entanglement registries and the two lifecycle hooks throw `not available until phase 2 PR 4/5`
rather than no-op — an accepting stub would let a module believe it had registered something.
28 tests, all on the failure paths.
- **PR 3** — schema fragment replay. `ensureSchema()` replays each installed module's fragment after
core's, with the statement splitter extracted to `utils/sqlStatements.js` so both are split by the
same code. The decision that shaped it, recorded in [`MODULE_API.md`](MODULE_API.md) §2.6: the
fragment is **validated at load time and executed later**, split on whether a database is needed to
know the answer — a fragment breaking a stated rule never mounts, while a failure only the server
could report (a bad column type) is post-mount and 503s. The rules are enforced as a **leading-verb
allowlist** (`CREATE`, `ALTER`, `INSERT`, `UPDATE`) rather than the `DROP` denylist §2.6 words them
as, because the file is replayed on **every boot**. Found while wiring it: `npm run seed` calls
`ensureSchema()` without ever requiring `app.js`, so the replay has to tolerate an unscanned loader.
- **PR 4** — the three de-entanglement registries, `src/modules/registries.js`. Core's own streams,
its Discord announce leg and its users-detail routes all go through them, so the seams are
exercised on every boot before a module depends on them; §1.8 and §1.9 above record what each
became. Four decisions landed with it, all recorded in [`MODULE_API.md`](MODULE_API.md): announce
legs became a **child table** rather than waiting for Phase 3 (§2.4 — a module cannot alter a core
table, so a registered leg had nowhere to live); **`mapEvent` dropped** from the stream registry
(§2.4 — a leftover from before the push inversion was settled); **core registers through the same
staging area a module uses**; and core's six shard sub-paths **moved behind the slot now** rather
than in Phase 3.
Registering is **validate-then-commit**: the loader stages a module's claims and the second pass
commits them, so a module that throws halfway through `register()` — or fails a later validation
step — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule.
Two build tools needed teaching, both because a mechanism this PR introduced is one they had never
seen. `scripts/routeManifest.js` could not decode a **parameterised mount**: its unwinder expected
a group shape express does not emit, and the branch had never run. It threw rather than guessing,
which is exactly what it is for. And `swagger-autogen` could not follow a route into an extension
**slot**, deleting 407 lines while reporting success; the fix is the fragment merge core owed
anyway ([`MODULE_API.md`](MODULE_API.md) §6.6).
There is still no module on the volume and no boot wiring, so this changes nothing an operator or a
client can see: **884 tests pass** and `routes.manifest.json` is unchanged at 229 routes. The two
lines of OpenAPI that do move are the retry endpoint's summary and its `leg`, which is no longer a
fixed enum because the leg set is whatever has been registered.
- **PR 5** — boot/shutdown dispatch and the `installed_modules` reconcile, `src/modules/lifecycle.js`.
`api.onBoot`/`api.onShutdown` stop throwing, `server.js` gains one call on each side, and the
§2.4 machine finally runs against real outcomes — which is what makes §4.5's `disabled` 404 leg
reachable for the first time. Four decisions landed with it, all recorded in
[`MODULE_API.md`](MODULE_API.md) §2.5 and §4.4: **the loader classifies its failures** by §4.3 step,
so `failure_stage` says where a module broke instead of being a column nothing filled; **a row whose
directory is gone is marked failed** rather than left claiming `enabled` (§2.4 above); **core's eight
UO boot call sites stay in `server.js`** until Phase 3, because unlike a registered announce leg a
boot call site already has somewhere to live and moving it now would be extraction done early in a
phase whose exit criterion is that nothing changes; and **`onBoot` gets no timeout** — shutdown races
a SIGKILL and boot does not, and a slow `onBoot` delaying the listener is the contract's promise to
a module that must warm up before it serves.
The dispatch lives outside the loader for the reason the schema replay does: the loader is required
by `app.js` against a dead pool, and this half is database-first. They meet at one function,
`loader.setState()`, so the in-memory record the dispatch guard reads and the row the admin panel
reads cannot drift apart.
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.
- **PR 7** — the client half's delivery: `client/src/modules/registry.js`, `window.__rg`
(`modules/shared.js`), the chunk's static mount and the `htmlShell` injection, with `App.jsx`
reading `routesFor` for all three areas. The registry is empty on a bare core, so nothing an
operator can see changes. Four decisions, all recorded in [`MODULE_API.md`](MODULE_API.md) §3.1 and
§3.4: **routes now, nav in PR 8** — PR 7 is "a module chunk loads and renders its page", PR 8 is
"it appears in the nav", which keeps the nav interleave and its override merge in one reviewable
change; **the script tag is injected before `</body>`**, not into `</head>`, so the ordering that
the whole client contract rests on comes from document structure rather than from Vite's choice to
hoist core's entry into `<head>`; **the static mount is rooted at the entry's directory, behind the
module's state guard, with `no-cache`** — a mount rooted at the module root would publish server
source, `module.json` and the schema fragment, so an entry in the module root is rejected outright;
and **the UI kit ships its seven real members**, with `AdminPage` struck from the contract rather
than invented in core to satisfy a table.
The verification that mattered was **not a test**. Everything passed against a build that did not
work in a browser: core mounted before any module chunk had evaluated, because `document.readyState`
during a deferred script is `'interactive'`, not `'loading'`. A module's routes were missing from
the first render and its URL redirected home — indistinguishable from a module that failed to load,
and with nothing logged anywhere. It was found by loading a hand-written chunk in Chrome, and that
smoke is now written down as part of the contract ([`MODULE_API.md`](MODULE_API.md) §7.7) because no
test in this repo can see it. The same run confirmed the property §3.6 called the highest-risk
detail in the plan: the chunk executes under the **enforced** `script-src 'self'`, resolving core's
React and UI kit off the global, with zero CSP reports.
**933 server tests** (+23) and **123 client tests** (+14) pass; `routes.manifest.json` is unchanged
at 230 routes and the OpenAPI spec regenerates byte-identical — `/modules/<id>/` is a
filesystem-conditional static mount, not API surface, for the same reason `/uploads` and `/brand`
are not in the manifest.
- **PR 8** — the nav half PR 7 deferred, and the two seams §1.4 and §1.5 asked for: `withModuleNav`
(`client/src/modules/nav.js`) interleaving module rows into core's three navs, `MOD_PATHS` and the
moderator redirect replaced by a `roles`-derived computation in `client/src/lib/adminNav.js`, and
the generic feature-provider seam (`modules/features.jsx` + `modules/featureGate.js`) that core
registers its own `useShardFlags` into. Four decisions, all recorded in
[`MODULE_API.md`](MODULE_API.md) §3.3.
**The interleave happens before the admin-override merge**, which is the decision the rest follow
from: the merge is keyed by `to` and drops any key its base array does not declare, so module rows
appended after it would be unorderable, unrelabellable and unhideable — and today's UO rows are
all three of those things, so appending would make the extraction a visible regression for every
operator who has ever edited their nav. Doing it first means a module row is an ordinary row to
everything downstream: nothing in `navOverrides.js`, `NavEditor.jsx` or the layouts knows a module
exists. **Moderator visibility derives purely from `roles`**, which moves two rows the old
allowlist withheld — Dashboard, whose `roles` had always named moderator, and My Characters, which
is ungated self-service — both toward what the server already permitted. **A row's `feature` is
resolved by the provider its own module registered**, so the namespace comes from the registration
rather than from a parsed string prefix. And **core registers through the same seam**, under the
owner id `core`, so `SiteHeader` holds one mechanism instead of two and Phase 3 is a deletion.
The PR also fixed a defect that predates the module system: the moderator redirect was a **third**
hardcoded list, and it disagreed with `MOD_PATHS` about `/admin/houses`, so a moderator who
clicked Houses in their own sidebar was bounced back to Moderation. The derived allow-list is
computed from the **base** nav rather than the merged one, so an override — which is presentation
— cannot move that boundary in either direction.
**933 server tests** (unchanged — this PR is client-only) and **160 client tests** (+37) pass;
`routes.manifest.json` is unchanged at 230 routes and the OpenAPI spec regenerates byte-identical.
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, since this is the seam that rule exists for: a throwaway module
registering nav in all three areas and a provider granting one flag and withholding another. It
confirmed, in Chrome with the console open, that the row lands inside core's Moderation group
rather than in an appended block, that the withheld row does not render while the granted one
does, that a moderator reaches both `/admin/houses` and the module's own admin page, and that an
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;
@@ -419,12 +746,203 @@ Acceptance, all four required:
ownership move, which changes no URL. After extraction the core manifest no longer contains UO
routes — `module-uo` generates and freezes its own in its own repo.
4. **A written `module-rust` dry run** — manifest, mounts, nav entries, one notification stream — not
implemented, to prove the contract generalises before more is built on it.
implemented, to prove the contract generalises before more is built on it. It lands as
`docs/modules/rust-dryrun.md`, where §2.10 already aggregates module documentation; Phase 5's
Integration Kit links to it rather than copying it, per the kit's own never-re-specify rule
(§2.11).
#### 2.7.1 Phase 3's shape — settled 2026-08-11
Measured against `edge` at the close of Phase 2, the surface is **72 server files / ~9,700 lines**,
**51 client files / ~3,700 lines**, and **32 of core's 82 server test files**. The counts in the
paragraph above were written in Phase 0 against a smaller tree and are superseded by the slice table
below.
**The finding that sets the order: the two halves are independent.** Because §1.2 preserves API URLs
exactly, core's client keeps calling `/api/v1/public/shard/status` after that route is served by the
module, and a module page calls the same URL while core still serves it. Nothing forces a feature's
server and client halves to move together, so the extraction is **server-first, then client**, sliced
by feature — which keeps each PR inside one layer and one review's worth of context.
**Merge order across the two repos: `module-uo` first, then `website`.** The loader's `ownedByCore`
probe means a module cannot *load* while core still owns its prefix — but `module-uo`'s own CI never
loads it into core, so its PR merges perfectly well beforehand. Taking that order means `edge` serves
the feature from core right up to the moment core drops it, and there is never a window where the
branch is missing a feature outright. The reverse order would break `edge` at every slice boundary
for the length of a review. Verification is unaffected either way: a slice is proved by running the
*pair* together locally — the module branch checked out into `website/modules/uo`, the deletion
branch checked out in `website/` — before either merges.
Each slice is one `module-uo` PR (adds), one `website` PR (deletes), and one `docs` PR:
| # | Slice | Moves |
| --- | --- | --- |
| 0 | **The bundle skeleton** | `module.json`, both `package.json`s, `server/index.js` registering nothing, the Vite library build + the four shared-dep shims, CI armed, the §5.1 zero-internal-imports check. `website` untouched. |
| 1 | **The whole server half** | 40 files / ~9,674 lines, 25 of core's 82 test files, 27 of its 68 tables — every UO model, util, router and controller, `config/shardStreams.js`, `scripts/importSpawnAtlas.js` and the art JSON. **One merge, five commits** (below). |
| 2 | **Public pages** | `Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`, `PlayersOnline`, `VendorSales`, `data/cityCrests.js`, `lib/shardEvents.js`, `lib/useShardFeed.js`, and their public nav rows — under `/uo/*` per §2.8 |
| 3 | **Admin + player pages** | `ShardAdmin`, `ShardOps`, `ShardVisibility`, `SpawnAtlas`, `HousesAdmin`, `AdminCharacter(s)`, `PlayerCharacter(s)`, `GameAccounts`, `CharacterSheet`, `CharacterStats`, `ShardAccountActions`, `CreateGameAccountForm`, `useShardFeatures` and the `useShardFlags` feature provider — under `/admin/uo/*` and `/player/uo/*` |
| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `SiteFooter`, `heroLayout`'s defaults, `api/client.js`'s `shard`/`atlas` namespaces, and the comments in `navOverrides.js` — plus the §5.2 CI grep that keeps them out |
| 5 | **Close the phase** | `module-uo`'s frozen route manifest and release workflow; `docs/modules/uo/` and `docs/modules/rust-dryrun.md` |
##### Why the server half cannot be sliced — found 2026-08-11, before writing any of it
The table above used to run to ten slices, with the server half split five ways by feature. It does
not divide, and the reason is that two contract rules compose:
- **A mount prefix is claimed whole.** `ownedByCore` probes the live tier router and `registerRoutes`
validates single-segment prefixes, so `/admin/shard` moves as one unit — and it is a single
| 4a | The contract lives in [`MODULE_API.md`](MODULE_API.md); it is normative where the two differ | §2.7 |
| 4b | Modules ship an OpenAPI **fragment**; core merges started modules' fragments into `/api/docs.json` | API §6.1 |
| 4c | Core exposes a **curated, closed** UI kit + request primitive on `window.__rg`, versioned by `MODULE_API_VERSION` | API §3.4 |
| 5 | Install surfaces: admin panel and the Docker environment; never a build step | §2.5 |
| 6 | One repo, one bundle — server and client halves version together | §2.3 |
| 7 | Android app is a separate plan; core owes it `/api/v1/public/modules` | §2.5, §2.7 |
@@ -507,3 +1081,8 @@ Conventional Commits, the AI-disclosure trailer, branches cut from an up-to-date
| 10 | Client half loads as a prebuilt ESM chunk with React shared via a core global | §2.6 |
| 11 | Website work lands on `edge` and reaches `main` as one cutover at the end | §2.9 |
| 12 | The module repo is `RunicGateway/Module-uo`; the module id is `uo` | §2.3 |
| 13 | `RunicGateway/Integration-kit` is the module-builder's instruction book — module + sidecar + game plugin, teaching only, never re-specifying a contract | §2.11 |
| 14 | Phase 3 extracts **server-first, then client**, sliced by feature; `module-uo` merges before `website` in each pair | §2.7.1 |
| 15 | Criterion 1's grep reads **code, not prose**; core's UO copy is rewritten in its own slice instead | API §5.2, §2.7.1 |
| 16 | `module-uo`'s CI checks core out at a **pinned ref** to generate its frozen route manifest | API §5.3 |
| 17 | The `module-rust` dry run lands as `docs/modules/rust-dryrun.md`; the Integration Kit links to it | §2.7.1, §2.11 |
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_job_legs` for due/retry legs (whichever are registered — Discord is core's, the town crier is module-uo's) |
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
---
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.