Compare commits

28 Commits

Author SHA1 Message Date
f3a6231084 docs(website): record slice 1, and MODULE_API 1.1.0
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>
2026-08-11 12:11:09 -05:00
f402395fa5 docs(website): the server half does not slice, and why
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>
2026-08-11 01:50:37 -05:00
7548c20820 docs(website): correct the library build, and record slice 0
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>
2026-08-11 01:33:38 -05:00
749233d378 docs(website): settle Phase 3's shape, slices and merge order
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>
2026-08-11 01:10:13 -05:00
12e4eaad10 Merge pull request 'docs(website): Phase 2 PR 9 — the modules mount, and Phase 2 complete' (#134) from docs/module-compose-volume into main
Reviewed-on: #134
2026-08-11 05:57:58 +00:00
8a7b099c2d 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>
2026-08-11 00:50:17 -05:00
d66ee832c5 Merge pull request 'docs(website): Phase 2 PR 8 — the nav interleave and the feature-provider seam' (#133) from docs/module-nav-interleave into main
Reviewed-on: #133
2026-08-11 05:29:18 +00:00
f740530eb8 docs(website): settle the nav interleave and the feature-provider seam
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>
2026-08-10 23:42:21 -05:00
b43b40e71b Merge pull request 'docs(website): Phase 2 PR 7 — the client chunk's delivery contract' (#132) from docs/module-client-registry into main
Reviewed-on: #132
2026-08-11 04:01:28 +00:00
17d8608a7f docs(website): record Phase 2 PR 7 — the client chunk's delivery contract
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>
2026-08-10 22:54:39 -05:00
fdede79909 Merge pull request 'docs(website): settle what core publishes about an installed module' (#131) from docs/module-public-endpoint into main
Reviewed-on: #131
2026-08-11 03:16:23 +00:00
6a39ef63c6 docs(website): settle what core publishes about an installed module
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>
2026-08-10 22:03:35 -05:00
e733ac0c9c Merge pull request 'docs(website): settle boot/shutdown dispatch and what a boot writes down' (#130) from docs/module-lifecycle into main
Reviewed-on: #130
2026-08-11 02:32:48 +00:00
004e217806 docs(website): settle boot/shutdown dispatch and what a boot writes down
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>
2026-08-10 20:32:10 -05:00
30589f1fa5 Merge pull request 'docs(website): settle the three de-entanglement registries and what they cost' (#129) from docs/module-registries into main
Reviewed-on: #129
2026-08-10 23:16:46 +00:00
58b435be70 docs(website): settle the three de-entanglement registries and what they cost
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>
2026-08-10 17:51:02 -05:00
bcd3a750e7 Merge pull request 'docs(website): settle how module schema fragments are validated and replayed' (#128) from docs/module-schema-replay into main
Reviewed-on: #128
2026-08-10 22:05:57 +00:00
63e6c2b5d1 docs(website): settle how module schema fragments are validated and replayed
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>
2026-08-10 16:58:11 -05:00
510d10b297 Merge pull request 'docs(website): settle the module loader's trigger and its collision check' (#127) from docs/module-loader into main
Reviewed-on: #127
2026-08-10 21:30:52 +00:00
d7054fd1f2 Merge pull request 'docs(website): plan the Integration Kit — the module-builder's instruction book' (#126) from docs/module-integration-kit into main
Reviewed-on: #126
2026-08-10 21:30:29 +00:00
9355f2aec4 docs(website): settle the module loader's trigger and its collision check
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>
2026-08-10 14:36:22 -05:00
bb0e6a02fe docs(website): plan the Integration Kit, the module-builder's instruction book
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>
2026-08-10 14:10:41 -05:00
b5277d827c Merge pull request 'docs(website): where the module state machine lives, and how boot treats it' (#125) from docs/module-state-machine into main
Reviewed-on: #125
2026-08-10 19:02:48 +00:00
3510c2ecf1 docs(website): settle where the module state machine lives and how boot treats it
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>
2026-08-10 06:35:37 -05:00
27bfdc9152 Merge pull request 'docs(website): the module API contract, validated by the atlas spike (phase 1)' (#124) from docs/module-api into main
Reviewed-on: #124
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-10 11:16:46 +00:00
037f4abad2 docs(website): record what the atlas spike proved
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>
2026-08-10 05:30:05 -05:00
8db52c3d14 docs(website): settle the OpenAPI fragment merge and the curated UI kit
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>
2026-08-10 04:52:59 -05:00
2c2a5ccd57 docs(website): add the module API contract (phase 1)
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

View File

@@ -24,6 +24,8 @@ sidecar as a service, and hands you the values the website needs.
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
| [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 |

View File

@@ -105,6 +105,12 @@ server/
NOT under /shard: nothing here
touches the sidecar, and unlike
/shard it IS site-mode gated
modules.router.js (1) /public/modules — the installed-module
list a client feature-detects against.
A real prefix and not a fifth singleton
below, so the module loader's
collision probe (which skips
root-mounted layers) sees it
site.router.js (4) /settings /status /version /contact —
the group-root singletons; declares no
router-level middleware
@@ -365,7 +371,7 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`modules/registries.js` — core's plus every installed module's), validated on write |
| created_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
@@ -665,6 +671,42 @@ JSON. `resolveMany()` returns only ids that resolved to something displayable
`~1_val~` are stripped, since the bridge sends the id and never the property packet that carries the
arguments — and it never throws, because a cliloc lookup is decoration on a character sheet.
### installed_modules — what is installed, and what happened to it (module system)
One row per installed module, keyed by the `id` from its `module.json` — the same id that names its
directory on the modules volume and its URL segment.
| Column | Shape |
|---|---|
| `id` | VARCHAR(32) PK — the module id |
| `name`, `version` | the manifest's label and semver, for the admin Modules screen |
| `state` | ENUM `installed` / `enabled` / `disabled` / `started` / `startup_failed` |
| `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
@@ -765,14 +814,18 @@ registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscr
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
`config/notificationStreams.js`. Security invariants:
create/publish-post path for `news.post`. The catalog is assembled at boot by
`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus
each installed module's; the shard streams and their event→stream mapping are
`config/shardStreams.js`, which belongs to module-uo and moves out with it
(MODULE_SYSTEM.md §1.8). Security invariants:
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
push.
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
`/player/shard/*`).
`/player/shard/*`) in `utils/shardPush.js`. `utils/pushDispatch.js` itself only publishes to a
stream id someone else resolved — it has no idea what a shard event is.
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
@@ -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

1191
website/MODULE_API.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,13 @@
# The Module System — design of record
**Status:** approved design, not yet implemented. Every decision in Part 3 has been settled with the
org lead; Part 1 records what was verified against the working trees on 2026-08-10, including the
places the original draft was wrong.
**Status:** approved design, **in implementation** — Phase 2's core scaffolding is landing on the
website `edge` branch, PRs 1–7 of 9 done (§2.7 tracks what each settled). Every decision in Part 3
has been settled with the org lead; Part 1 records what was verified against the working trees on
2026-08-10, including the places the original draft was wrong.
**The normative contract is [`MODULE_API.md`](MODULE_API.md)** (Phase 1). This document decides what
the module system *is*; that one decides exactly what a module may call. Where the two differ, that
one wins — its Part 6 lists every place it amends this document.
**Goal.** Turn Runic Gateway from a UO/ServUO-specific platform into a game-agnostic one. The core
architecture is unchanged — sidecar → website → browser. What changes is that game-specific
@@ -92,6 +97,14 @@ moderator-visible.
**Resolved:** nav registration takes a target group and order (`{ group: 'Moderation', order: 30 }`),
and `MOD_PATHS` becomes a `roles`-derived computation rather than a path allowlist.
Built in Phase 2 PR 8. Two things it turned up that this section did not predict. The interleave has
to happen **before** the admin-override merge and not after it, because that merge drops any `to`
its base array does not declare — appending module rows afterwards would leave them uneditable in
Admin → Navigation, which today's UO rows are not. And there was a **third** hardcoded list: the
redirect that confines a moderator checked three path prefixes, while `MOD_PATHS` listed five paths,
and they disagreed about `/admin/houses` — a moderator who clicked Houses in their own sidebar was
bounced straight back to Moderation. One derivation cannot disagree with itself.
### 1.5 The public nav's feature-gating mechanism is itself a shard system
Ten of the sixteen entries in `SiteHeader.jsx`'s NAV carry a `feature:` key (`status`, `champs`,
@@ -103,6 +116,12 @@ Extracting the module removes the provider that core's own nav filter depends on
registers its `useShardFeatures` for its own namespace. No core nav item carries a `feature` today,
so with no module installed the filter is a correct no-op.
Built in Phase 2 PR 8, and core registers into it **now** rather than at extraction: `useShardFlags`
goes in under the owner id `core`, so the ten rows above are already resolved through the seam and
`SiteHeader` runs one mechanism instead of two. Which provider answers a row is decided by the
module that registered it, not by a prefix parsed out of the flag name, so those ten keep the exact
strings they carry today and Phase 3 moves them without a rename.
### 1.6 There is no migration system to model a module migration runner on
`server/db/schema.sql` is a single idempotent file — 1,380 lines, 67 tables — replayed in full on
@@ -120,7 +139,9 @@ replayed; **purge** is a separate, explicit, destructive admin action that runs
`purge.sql`. A real migration runner, covering core *and* modules together, is a legitimate future
workstream; it is not a prerequisite for this one.
Twenty-five of the 67 tables move with the module: the 24 `shard_*` tables plus `uo_link_config`.
Twenty-seven of the tables move with the module: the 26 `shard_*` tables plus `uo_link_config`.
(This said 25 when written; the working tree was recounted in Phase 1 — see
[`MODULE_API.md`](MODULE_API.md) §6.4.)
### 1.7 Boot and shutdown is a lifecycle gap
@@ -147,16 +168,36 @@ Everything else is a folder move. These are not:
1. **`src/config/notificationStreams.js`** — the push stream catalog. `mapShardEvent()` and most of
`STREAMS` are shard-derived, and it imports `PUBLIC_KINDS` from `utils/shardBroadcast`. Push
*infrastructure* is core; this *catalog* is module content.
→ `registerNotificationStreams({ streams, mapEvent })`.
→ `registerNotificationStreams(streams)`.
2. **`src/utils/pushDispatch.js`** — core infrastructure, but `fromShardEvent()` (line 112) requires
the `shardLinks` model (line 21) and `mapShardEvent` (line 23).
→ invert: `publish()` stays core, `fromShardEvent` moves into the module and calls it.
3. **`src/utils/announceWorker.js`** — the news dispatcher, with two delivery legs: Discord (core)
and town crier (module, via `uoLinkClient.postTownCrier`, line 36).
→ `registerAnnounceLeg({ leg, dispatch, classify })`.
→ `registerAnnounceLeg({ leg, label, dispatch, classify })`.
`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 `<script type="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
`<script type="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
386-line router carrying 25 routes that span atlas, clilocs, shard-ops, visibility, market *and*
account links.
- **A model cannot be shared across the boundary** (§5.1), so a model moves with the *last* route
that consumes it.
Take the closure and every prefix is in it:
```
/public/atlas ──shardAtlas── /admin/shard ──shardState,shardEvents,shardMarket── /public/shard
│ │
shardClilocs,shardLinks uoLinkConfig
│ │
/player/shard /admin/uo-link
```
Landing any one of the old slices alone would either strand core importing `modules/uo/` — which is
precisely 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.** `/admin/atlas` and
`/admin/clilocs` alongside a slimmer `/admin/shard` would make the closure fall apart. It also
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
(`data/api/AdminApi.kt`). A prefix rename is a client break, and the API surface is frozen for
exactly this reason.
**So slice 1 is one PR per repo, structured as five commits** along the old slice lines, reviewable
one at a time while landing atomically: atlas + clilocs · the live shard · market · account links and
the `admin.users.detail` slot · the town-crier leg. The alternative considered was stacked PRs into a
per-repo integration branch; it buys PR-level granularity for ten extra PRs and two long-lived
branches, and commits give most of the same reading order for none of it.
**The client half is unaffected** and still slices cleanly: the client registry takes routes per
*area*, with no prefix atomicity and no shared models — which is the same asymmetry that let the two
halves be separated in the first place.
**Criterion 1 is a grep over code, not over prose** — see [API §5.2](MODULE_API.md#52-zero-uo-identifiers-in-core-ci-website-repo)
for what that means precisely. Core's marketing copy says "shard" in a dozen places, and a literal
word grep would have made every one of them a CI failure while proving nothing about the boundary.
Slice 4 rewrites that copy anyway, because a core that still reads as a UO site is not the
game-agnostic platform this workstream is for — but it is a deliberate piece of work with its own
review, not an exemption hidden in a grep pattern.
**One small gap in the kit, deliberately not closed.** The UO client views import almost exactly the
seven §3.4 members — plus `lib/format.js`, a pure leaf formatter. The module **vendors a copy**
rather than core adding an eighth member: the kit is closed on purpose, and a function with no
props and no layout cannot drift the way a component can. The same is not true of `PublicLayout`,
which is why that one is in the kit.
#### Slice 0 — the bundle skeleton (Module-uo#2, 2026-08-11)
`module.json`, an entry point taking `(ctx, api)`, the Vite library build, four shims, and both
boundary checks. It **registers nothing**, and core is untouched — what it proves is the delivery
path itself, before a single UO file moves into it. 29 server tests and 9 client tests, both new.
Verified against a real core rather than asserted: the module loads, mounts its zero routes, reaches
`started` and is published by `/api/v1/public/modules`; its chunk serves from the entry's directory
with `Cache-Control: no-cache` while its server source, `module.json` and `package.json` all 404;
and in Chrome, under the enforced `script-src 'self'`, the chunk reports every shared dependency
**identity-equal** to core's, with zero CSP reports.
**Three findings, each of which had produced a green build that was wrong.** The first amends the
contract and is written up at [API §3.6](MODULE_API.md#36-vite-library-mode-build): `external` and
the aliases do not compose, so `external` is now empty and a resolution-time build guard replaces
it. The second is that guard's own two failures — hooking `load` (first-wins, so it never ran) and
deriving its forbidden list from the alias list (so deleting an alias deleted the guard). Both were
found by breaking an alias on purpose and checking the build actually went red, which is the only
way a guard's absence is visible.
The third is about the boundary check itself and generalises past this repo. **`checkImports.js`
failed on its own documentation** — the comment naming `require("../../etc/passwd")` as an example
of what to catch, and the entry point's comment explaining why a module must never
`require('express')`. A check that cannot survive being described is one people stop writing
comments around, so it strips comments and template literals with a character walk rather than a
regexp (a URL in a string contains a comment opener; a comment contains quotes) and carries its own
test suite. The same applies to slice 4's §5.2 grep, which will be read by a codebase that discusses
modules constantly.
#### Slice 1 — the whole server half (Module-uo#3 + website#137, 2026-08-11)
40 files, ~9,674 lines, 27 of 68 tables, 25 of 82 test files. Core no longer contains anything that
knows what a shard is. Three commits per repo, readable in order.
**The acceptance criterion held exactly.** Core's `routes.manifest.json` goes 228 → 158 public
routes, and the 70 that left reappear byte-identical once the module is loaded — proved by generating
the manifest against core+module and diffing it against the pre-extraction file: zero missing, zero
added, and `routes.guards.json` identical across all 228, so no auth gate moved either.
**`server/core.js` is the port mechanism and the shape is the finding.** Ported code requires its
dependencies at file scope, which runs before `register()` and therefore before any `ctx` exists — so
every member of that file is a stable function resolving `ctx` when *called*, and nothing may be
destructured off `ctx` at init either, because core is free to hand over a getter. That kept the port
to a one-line import change per file instead of a signature change per function. Its consequence:
**require order is load-bearing.** A router does `const express = core.express` at its own file
scope, so `core.init(ctx)` must run before the first `require` under `router/`, and the module's
entry point requires its routers inside `register()` for exactly that reason.
**The contract grew to 1.1.0**, four members, none of which could be avoided:
`ctx.activity.log` (an admin action a module performs belongs in core's *one* audit log — a module
with its own is a second place to look, which means a place nobody looks), `ctx.users.getById`,
`ctx.site.baseUrl`, and `ctx.middleware.rateLimit` + `accountChangeLimiter`. The rate-limit split is
worth restating: a module states its own window and cap because it knows what its endpoints cost, and
takes the plumbing from core so there is one `express-rate-limit` in the process and one place a
breach is logged.
**`registerPostHook` is the fourth registry and the last coupling removed** — see
[API §2.4](MODULE_API.md#24-api--what-the-module-registers).
**What was vendored, and what deliberately was not.** `deriveExcerpt` came across as nine lines of
pure text handling; core's **sanitiser** sitting beside it did not, because a second copy of a
security control diverges silently the moment either is fixed. That is the line: pure leaf helpers
may be copied, controls may not.
**Two defects the extraction exposed, both in core.** The loader matched `CREATE TABLE` against the
**raw** fragment, so a schema file whose header says "every CREATE TABLE carries IF NOT EXISTS" was
rejected for a prefix violation on a table called `carries` — the same class as slice 0's boundary
check failing on its own documentation, and now fixed on both scans by reading split statements. And
the atlas art map resolved `../../../db/data`, correct in core and pointing outside `server/` in the
module: a path that happens to resolve is exactly what survives a green suite, because the
absent-file branch returns `{}` and looks like the normal case. It was caught by the integration run,
not by tests.
**One deliberate behaviour change.** `uoLinkSocket.start()` and the sidecar health probe used to run
*after* the listener bound and now run before it, because `onBoot` does. `start()` returns as soon as
the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and **not**
awaited — an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is
diagnostics; being up is not a precondition for serving a page.
**One test stayed that looked like it should move.** `playerRouteAccess.test.js` guards a real past
bug — an admin 403'd off their own characters — through a now-module-owned URL, but the *guarantee*
is core's: `/player/*` is role-agnostic self-service. It stays and asserts that through
`/player/appeals`. Moving it would have left core with no test of its own tier rule, which is
precisely what regressed once before.
**A note for anyone running core's suite locally: remove `modules/uo` first.** With a module
installed the manifest tests fail correctly — core's committed manifest is core-only, and the live
stack has the module's routes on it.
**One thing to know before running a module locally: the loader skips a *symlinked* module directory
silently.** `readdirSync(…, { withFileTypes: true }).filter(e => e.isDirectory())` reports a Windows
junction as a symlink, so a module linked rather than copied into `modules/` is simply not there,
with nothing logged. Not a defect for a real install — `modules/` is a bind mount of real
directories (§2.5) — but it is the first thing to check when a module fails to appear.
**Phase 4 — Delivery.** The admin-panel Modules screen (install, enable, disable, retry, purge,
`startup_failed` with its recorded reason) and the Docker-environment path from §2.5. Deliberately
last, so loader, packaging, schema and chunk-loading problems are not all being debugged at once.
**Phase 5 — The Integration Kit.** `RunicGateway/Integration-kit`, the instruction book for building
a module for a game that is not UO — the website module, the sidecar and why it exists, and the
game-side plugin that feeds it. Scaffolded when Phase 2 lands, written against Phase 3's extraction,
finished alongside Phase 4. Full shape and its acceptance test in §2.11.
### 2.8 SPA URL namespacing — a deliberate break
**Decision: module pages are namespaced, and old paths are not redirected.** The site is not public
@@ -489,6 +1007,59 @@ merely regenerated) and `npm test`, and carries a matching edit to `BACKEND_DESI
documentation aggregates in this repo under `docs/modules/<id>/` rather than living in module repos.
Conventional Commits, the AI-disclosure trailer, branches cut from an up-to-date `main`.
### 2.11 The Integration Kit — the instruction book for building a module
**`RunicGateway/Integration-kit`** — `https://gitea.whitlocktech.com/RunicGateway/Integration-kit.git`,
**empty as of 2026-08-10**: no branches, no initial commit, exactly where `Module-uo` was at the start
of Phase 0. Its first commit needs the same scaffolding as any other repo here — `README.md`,
`LICENSE.md` (GPL-3.0-or-later), `CONTRIBUTING.md` with the AI-disclosure clause, the PR template.
**Who it is for.** Everything else in this plan is written for someone changing *this* system. The kit
is written for someone building a **new** one: a person who wants Runic Gateway to front a game that
is not Ultima Online, starting from nothing. It is the only document in the project whose audience is
outside the org, and that changes how it is written — it explains and motivates rather than records
decisions.
The job spans all three layers of the data path, which is why it is one book and not a page in each
repo:
1. **The website module.** `module.json`, the server entry point and what `ctx` hands you, the
`register*` calls, the schema fragment, the prebuilt client chunk and the shared-dependency rule,
packaging and release CI. The bulk of it.
2. **The sidecar** — what it is and, more importantly, *why*. The shard is never network-reachable;
the shard dials **out** and the sidecar is the listener; the wire is a versioned compatibility
contract rather than a build dependency; only the website's backend talks to it. A new game needs
its own sidecar or an adapter into the existing one, and neither can be designed by someone who has
been handed the message list and none of the reasoning.
3. **The game-side plugin** — how a shard feeds the sidecar without ever letting the sidecar stall the
game: the bounded drop-oldest queue, the dedicated writer thread, world reads only on the game's
own thread. `servuo-plugins/` is the worked example; the constraints are general, and a plugin that
ignores them takes the game down when the sidecar wedges.
**The rule that keeps it from rotting: the kit never re-specifies a contract.**
[`MODULE_API.md`](MODULE_API.md) stays normative for the module surface, and
[`../link/PLAN.md`](../link/PLAN.md) + [`../link/INTEGRATION.md`](../link/INTEGRATION.md) for the wire
protocol. The kit *teaches* — worked examples, the reasoning, the order to do things in, the mistakes
that cost time — and links out for the authority. Where it must show a member list it quotes with a
pointer, never a copy. A guide that restates a contract diverges from it silently, and a reader who
follows the divergent copy gets a module that fails validation for reasons the guide cannot explain.
**It cannot be written before the contract is proven**, so it trails the implementation rather than
leading it:
- **Scaffolded once Phase 2 lands** — repo, license, CI, and an outline. By then a loader exists to
describe and a real module to point at.
- **Written against Phase 3's extraction**, using `Module-uo` as the worked example throughout. A kit
whose examples are invented is a kit whose examples do not compile.
- Phase 3's fourth acceptance criterion, the written `module-rust` dry run, is really this book's
first chapter — and doubles as the honest test that the contract generalises past its first module.
**Acceptance:** someone builds a trivial working module for a second game by following the kit alone,
without reading core's source. Until that has happened it is a draft, however finished it looks.
The org landing page (`RunicGateway/.profile`) and the workspace's `CLAUDE.md` repo table both gain a
row for it — when it has content, not while it is an empty repo.
---
## Part 3 — Settled decisions
@@ -499,6 +1070,9 @@ Conventional Commits, the AI-disclosure trailer, branches cut from an up-to-date
| 2 | Website and installer stay independent; delivery is website-side only | §1.11, §2.5 |
| 3 | Core declares an extension slot on `/admin/users/:id`; all six URLs preserved | §1.9 |
| 4 | Phase 1 spike targets `/api/v1/public/atlas/*` | §2.7 |
| 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 |

View File

@@ -536,10 +536,20 @@ Three existing behaviors the merge must not disturb:
no menu. `pruneNav` applies the gate inside a section and then drops one it
leaves empty.
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
redirects them out of anything else. Overrides apply before that filter, so a
moderator can still end up with a legitimately short sidebar — but the redirect
effect must keep working untouched.
- **Moderator confinement.** `AdminLayout` restricts moderators to the rows their
role carries and redirects them out of anything else. Overrides apply before
that filter, so a moderator can still end up with a legitimately short sidebar
— but the redirect effect must keep working untouched.
Amended by the module system's Phase 2 PR 8: this used to be a hardcoded
`MOD_PATHS` allowlist plus a second, differently-worded prefix check in the
redirect, and the two had drifted — `/admin/houses` was on the sidebar and not
in the redirect, so a moderator who clicked Houses was bounced to Moderation.
Both are now derived from each row's own `roles`
([`adminNav.js`](../../website/client/src/lib/adminNav.js)), and the redirect
derives from the **base** nav rather than the merged one, which is what keeps
an override from moving the boundary in either direction. See
[`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §1.4.
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
override that hides every item in a group must produce no orphaned header.

View File

@@ -781,6 +781,10 @@
"method": "POST",
"path": "/api/v1/public/contact"
},
{
"method": "GET",
"path": "/api/v1/public/modules"
},
{
"method": "GET",
"path": "/api/v1/public/pages/:id/preview/:token"

View File

@@ -482,7 +482,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
| `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`) |
---