diff --git a/README.md b/README.md
index 9609db4..2bb803a 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,8 @@ so they live in one place, independent of either codebase.
## Layout
```
-website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
+website/ docs from the website core (Node/Express + MariaDB + React/Vite)
+modules/ docs for installable game modules — one directory per module id
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
android/ docs from the native Android client (Kotlin + Jetpack Compose)
installer/ docs for the installer that deploys a shard's bridge components
@@ -35,6 +36,17 @@ sidecar as a service, and hands you the values the website needs.
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
+### `modules/`
+
+Documentation for installable game modules aggregates here rather than in each module's repo
+([MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) §2.10). The website core knows nothing about any
+particular game; a module is what makes it a site *for* one.
+
+| Doc | What it covers |
+|---|---|
+| [uo/](modules/uo/README.md) | **module-uo** — the Ultima Online module: what it serves, what it owns, and what an operator needs |
+| [rust-dryrun.md](modules/rust-dryrun.md) | A written, deliberately unimplemented `module-rust` — the test that the module contract generalises past the game it was extracted from |
+
### `link/`
| Doc | What it covers |
|---|---|
diff --git a/modules/rust-dryrun.md b/modules/rust-dryrun.md
new file mode 100644
index 0000000..8c9a401
--- /dev/null
+++ b/modules/rust-dryrun.md
@@ -0,0 +1,262 @@
+# `module-rust` — a dry run
+
+**Phase 3's fourth acceptance criterion** ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md)
+§2.7). A written design for a module serving a **Rust** (Facepunch) community, taken far enough to
+find out whether the contract generalises past the game it was extracted from — **and deliberately
+not implemented.** A contract validated only against the module it was carved out of has not been
+validated.
+
+The exercise is honest if it finds something. It found four things, one of which is a gap in the
+contract that a real second module would hit on its first day. They are in *Findings* at the end; the
+design comes first, because a finding is only worth anything with the design that produced it.
+
+Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions:
+it **wipes** every month, a community runs **several servers** rather than one shard, its identity
+is **Steam**, and its server speaks a **protocol nobody has to write** — RCON over WebSocket, built
+in. If the contract survives that, "game-agnostic" means something.
+
+> Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is
+> normative; this document only *uses* it.
+
+---
+
+## 1. The manifest
+
+```json
+{
+ "id": "rust",
+ "name": "Rust",
+ "version": "0.1.0",
+ "coreApi": "^1.3.0",
+ "server": "server/index.js",
+ "client": { "entry": "client/dist/entry.js" },
+ "schema": "server/db/schema.sql",
+ "purge": "server/db/purge.sql",
+ "mounts": {
+ "public": ["/rust"],
+ "admin": ["/rust"],
+ "player": ["/rust"]
+ },
+ "extensions": ["admin.users.detail"],
+ "capabilities": ["servers", "wipes", "leaderboards", "map", "killfeed", "teams"]
+}
+```
+
+One prefix per tier, named for the module rather than for a feature — the opposite of module-uo's
+`/shard` + `/atlas` + `/uo-link`, and the better choice for anything new. Module-uo's prefixes are
+what they are because §1.2 froze the URLs core already served; a module written today has no such
+debt and should claim one obvious segment. It also sidesteps the collision surface entirely:
+`/shard` is a word a second game might want, `/rust` is not.
+
+## 2. The server half
+
+```js
+module.exports = function register(ctx, api) {
+ core.init(ctx)
+
+ const publicRouter = require('./router/public/rust.router')
+ const adminRouter = require('./router/admin/rust.router')
+ const playerRouter = require('./router/player/rust.router')
+ const userExt = require('./router/admin/usersRust.router')
+
+ api.registerRoutes({
+ public: { '/rust': publicRouter },
+ admin: { '/rust': adminRouter },
+ player: { '/rust': playerRouter },
+ })
+
+ api.registerExtension('admin.users.detail', userExt)
+
+ api.registerNotificationStreams([
+ { id: 'rust.wipe', label: 'Server wipes',
+ description: 'A server wiped — new map, new seed, everything reset.',
+ personal: false, requiresLinkedAccount: false },
+ { id: 'rust.raid', label: 'Base raided',
+ description: 'Your base took damage while you were offline.',
+ personal: true, requiresLinkedAccount: true },
+ ])
+
+ api.registerAnnounceLeg({
+ leg: 'rust.ingame',
+ label: 'In-game chat',
+ dispatch: (post) => rcon.say(`[NEWS] ${post.title} — ${ctx.site.baseUrl}/news/${post.slug}`),
+ classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }),
+ })
+
+ api.onBoot(async () => { await rcon.connectAll() })
+ api.onShutdown(async () => { await rcon.closeAll() })
+}
+```
+
+Everything above is a call the contract already has, used the way module-uo uses it. Two details are
+worth pointing at:
+
+- **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven
+ bare stream ids are allowlisted because they were in `notification_subs` before the rule existed
+ (§6.5); a new module gets the rule, and the rule is exactly right.
+- **The announce leg goes to in-game chat over RCON**, which is a one-shot delivery with retry —
+ `registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that
+ has nothing in common with the one it was drawn for.
+
+### Tables
+
+`rust_servers`, `rust_wipes`, `rust_players`, `rust_player_stats`, `rust_teams`, `rust_events`,
+`rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment.
+
+**Every table that holds gameplay data carries a `wipe_id`.** That is the whole shape of the game in
+one column: a leaderboard means "since the last wipe", a base means "on this map", and a player's
+stats are per-wipe with an all-time rollup kept separately. It has no bearing on the contract —
+core replays the fragment and never looks inside — but it is the first thing a UO-shaped mental
+model gets wrong, and it is worth writing down for whoever builds this.
+
+### Talking to the game
+
+**No sidecar.** Rust ships RCON over WebSocket, so the module dials the server directly with the
+token an admin saved, encrypted at rest through `ctx.secretBox`.
+
+This is the sharpest test of whether the module system's boundary is drawn in the right place, and it
+passes: core has no opinion about how a module reaches its game. What core owns is that the module
+never blocks a request on it, that its secrets are encrypted, and that a game being down degrades to
+a page saying so. The *shard-dials-out* invariant that shapes
+[`../link/PLAN.md`](../link/PLAN.md) is a property of ServUO — a game engine with no remote-control
+surface, whose plugin must not stall on a socket — not of the platform. A game that ships RCON
+already answers the question the sidecar exists to answer.
+
+The Integration Kit ([`MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md) §2.11) should say this
+plainly, or its second reader will build a sidecar they did not need.
+
+## 3. The client half
+
+```js
+registry.registerRoutes('rust', {
+ public: [
+ { path: 'servers', element: },
+ { path: 'servers/:id', element: },
+ { path: 'servers/:id/map', element: },
+ { path: 'leaderboards', element: },
+ { path: 'wipes', element: },
+ ],
+ player: [
+ { path: 'account', element: },
+ { path: 'stats', element: },
+ ],
+ admin: [
+ { path: 'servers', element: , gate: { roles: ['admin'] } },
+ { path: 'ops', element: , gate: { roles: ['admin', 'moderator'] } },
+ ],
+})
+
+registry.registerNav('rust', {
+ area: 'public',
+ items: [
+ { label: 'Servers', to: '/rust/servers', group: 'Play', order: 10, icon: ServerIcon },
+ { label: 'Leaderboards', to: '/rust/leaderboards', group: 'Play', order: 20, icon: TrophyIcon,
+ feature: 'leaderboards' },
+ { label: 'Wipe schedule', to: '/rust/wipes', group: 'Play', order: 30, icon: CalendarIcon },
+ ],
+})
+
+registry.registerFeatureProvider('rust', 'rust', useRustFeatures)
+registry.registerExtension('rust', 'admin.users.detail', LinkedSteamAccounts)
+```
+
+`Play` is a group core does not have; §3.3 appends an unknown group rather than dropping the items,
+so this works and lands at the end of the nav — where an operator can move it, because a module row
+is an ordinary row once it is interleaved.
+
+The pages need `PublicLayout`, `PageHeader`, the three `PageState` components, `useAsync` and
+`useAuth`: **six of the kit's seven members**, and the seventh (`useSite`) on the wipe-schedule page
+for the site's timezone. A second game, unrelated to the first, wanting exactly what the kit
+contains is the strongest evidence available that §3.4 was curated at the right altitude.
+
+The map view is the one page that wants something the kit does not have — a pan/zoom canvas. It
+bundles one, which is the answer §3.4 already gives ("everything else a module bundles itself"), and
+it costs the chunk about 40 KB.
+
+---
+
+## Findings
+
+### 1. A module cannot register an identity provider — and Rust's identity is Steam
+
+The gap. Module-uo proves account ownership with an in-game `[link` command that issues a one-time
+code; the website confirms it with the sidecar. Nothing about that needs core's auth layer, so
+nothing in `api` ever needed to touch it.
+
+**Rust's answer is Steam OpenID**, and every Rust community expects "Sign in with Steam". Core has an
+SSO layer — `authProviders`, `userIdentities`, OAuth2/OIDC providers behind a registry — and
+[`MODULE_API.md`](../website/MODULE_API.md) §2.4 offers a module no way in. There is no
+`registerAuthProvider`, and §2.7 forbids reaching for one.
+
+A Rust module can still ship: it can copy the UO shape, issuing a code in-game and matching it on
+the website. That works, it is one screen worse, and it leaves the community's obvious expectation
+unmet.
+
+**This is not a defect in what was built** — it is the boundary of what was specified, found exactly
+where a dry run is supposed to find it. It is worth stating precisely, because whoever adds it has to
+answer a question the current policy already has an opinion about: **SSO is link-only by design**, an
+external identity must already be linked to an existing account, and identities are never
+auto-provisioned. A Steam provider a module registers must inherit that, not route around it. It is
+also *not* a small addition: an identity provider participates in session creation, which is the one
+part of core a module must never be able to weaken.
+
+Recommendation: leave it out of v1 of the contract and record it here as the first candidate for
+`MODULE_API_VERSION` 1.4 or 2.0, specified deliberately rather than bolted on when someone needs it.
+
+### 2. "One module, one game" is not the same as "one module, one server"
+
+A UO community runs one shard. A Rust community runs four or five, wipes them on different
+schedules, and every page is a per-server view.
+
+The contract is silent on this, and silence turns out to be right: multiplicity lives entirely in the
+module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
+and state machine are per-**module**, and none of them wanted to be per-server.
+
+Worth recording only because it looks like a problem until you try it — and because it is the shape
+that would have broken a contract designed around "the shard" as a singular noun. The phase-2
+inversions that removed core's opinions about game content (the push catalog, the announce legs,
+`mapEvent` dropped) are why it does not.
+
+### 3. The event catalog generalises; the *retention* assumption does not
+
+`rust_events` is the twin of `shard_events`, and the ingest shape carries over unchanged.
+
+What does not carry over is that a UO event log grows forever while a Rust one is **truncated every
+wipe**. That is module-internal — but it lands on something core does own: the schema fragment
+**must not** be where that truncation happens. §2.6's leading-verb allowlist (`CREATE`, `ALTER`,
+`INSERT`, `UPDATE`) already forbids `DELETE` and `TRUNCATE` in a fragment, precisely because the
+fragment is replayed on **every boot** and would empty the table each restart.
+
+So a wipe is a runtime operation on a module route, not a schema one. The allowlist was written for a
+different reason — the phase-2 note says "the file replays every boot, so TRUNCATE/DELETE would empty
+a table each restart" — and it correctly forbids the first mistake this module's author would make.
+A rule that catches a case it was not written for is a rule at the right altitude.
+
+### 4. `capabilities` earns its keep the moment there are two modules
+
+With one module, `capabilities` reads like decoration — core never interprets one, and the SPA knows
+what it registered. With two, it is the only thing a *client* can ask.
+
+The Android app is the case: it feature-detects against `GET /api/v1/public/modules` and must render
+a site whose module it has never heard of. `["servers", "wipes", "leaderboards"]` tells it there is
+nothing shard-shaped here without it having to know what `rust` means, and §2.9's rule — treat an
+unknown capability as absent, never infer a route from one — is what keeps that from becoming a
+second, worse route table.
+
+No change needed. Recorded because the design decision looked over-engineered with one module and is
+load-bearing with two.
+
+---
+
+## Verdict
+
+**The contract generalises.** A second game, chosen for how little it shares with the first, is
+served by the same `module.json`, the same seven registration calls, the same schema-fragment rules,
+the same client registry and the same UI kit — with one genuine gap (identity providers), one
+non-issue that looks like a gap (multiple servers), and two places where a rule written for one
+reason turns out to cover another.
+
+The gap is worth having found before something was built on top of it, which is what a dry run is
+for. What it does **not** establish is that someone outside this org could build this module from the
+documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested
+until a person who did not write any of this does it.
diff --git a/modules/uo/README.md b/modules/uo/README.md
new file mode 100644
index 0000000..85a70f1
--- /dev/null
+++ b/modules/uo/README.md
@@ -0,0 +1,97 @@
+# module-uo — the Ultima Online module
+
+Everything specific to Ultima Online that the website serves. Code:
+[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo). Module id `uo`,
+installed at `modules/uo/` on the website's modules volume.
+
+This page is **orientation**: what the module is, what it serves, what it owns, and what an operator
+has to know. It is not a second copy of the contract — [`../../website/MODULE_API.md`](../../website/MODULE_API.md)
+is normative for anything about how a module and core fit together, and the module's own repo is
+authoritative for its file layout. Where this page and either of those disagree, they win.
+
+Module documentation aggregates here rather than in module repos
+([`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) §2.10), so the feature docs that describe what
+these routes *mean* are the ones that already existed and did not move:
+
+| Doc | What it covers |
+|---|---|
+| [`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, parsed from the shard's own ServUO tree |
+| [`MARKETPLACE.md`](../../website/MARKETPLACE.md) | The player-vendor index |
+| [`CLILOCS.md`](../../website/CLILOCS.md) | UO's id → name table |
+| [`UOFIDDLER.md`](../../website/UOFIDDLER.md) | Operator runbook for extracting the cliloc table and creature art |
+| [`../../link/PLAN.md`](../../link/PLAN.md), [`../../link/INTEGRATION.md`](../../link/INTEGRATION.md) | The wire protocol this module speaks to the sidecar |
+
+---
+
+## What it serves
+
+**72 URLs**, frozen in the module's own
+[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json)
+and documented in its
+[`swagger-fragment.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/swagger-fragment.json),
+which core merges into `/api/docs.json` while the module is running.
+
+| Mount | Tier | What |
+|---|---|---|
+| `/api/v1/public/shard` | public | Status, activity feed, economy, presence, houses/IDOC, champs, guilds, governors, points boards, the player-vendor market, the ruleset, and the SSE event stream |
+| `/api/v1/public/atlas` | public | The bestiary: creatures, regions, landmarks, champion altars, and what is loaded |
+| `/api/v1/admin/shard` | admin | Shard ops (kick/ban/broadcast/pages), the visibility config, atlas and cliloc imports, market admin, account links |
+| `/api/v1/admin/uo-link` | admin | The sidecar connection config, live status, and the town crier |
+| `/api/v1/player/shard` | player | A player's own linked accounts: rosters, character sheets, vendors, sales |
+| `/api/v1/admin/users/:id/shard/*` | admin | Six routes filling core's `admin.users.detail` **extension slot** — a module's routes hanging off a *core* resource, since core owns the user |
+
+Every URL is byte-identical to the one core served before the extraction. That is the whole point of
+moving the code and not the paths: the shipped Android app calls
+`POST /api/v1/admin/shard/kick`, the Discord bot reads `/api/v1/public/shard/*`, and neither knows a
+module answers now.
+
+**In the SPA**: twelve public pages under `/uo/*`, the admin views under `/admin/uo/*`, the player
+views under `/player/uo/*`, three extension-slot fills, and the nav rows for all of them, interleaved
+into core's nav so an operator can reorder, relabel or hide them like any other row.
+
+## What it owns
+
+- **27 database tables** — 26 `shard_*` plus `uo_link_config`. Created by an idempotent
+ `schema.sql` fragment core replays on every boot, after its own schema. The `shard_`/`uo_link_`
+ prefixes are **grandfathered** ([`MODULE_API.md`](../../website/MODULE_API.md) §6.5): the rule for
+ a new module is `_`, and these predate it.
+- **Seven push notification streams** and the announce leg `towncrier`, likewise grandfathered —
+ they are stored in `notification_subs` and `announce_job_legs.leg` and read by the shipped Android
+ app, so renaming them would be a data migration plus a client break.
+- **Two settings rows**: `game_account_signup` and the shard's protocol pin.
+- **The public-safety filter.** Which shard event kinds may reach the *public* SSE stream is decided
+ here, not in core — the kinds, the streams and the filter are one file that moves together.
+
+## For an operator
+
+**Installing.** A release is `module-uo-.tar.gz` plus a manifest carrying its `sha256`.
+Unpack it as `modules/uo/` on the website's modules volume (or use the admin Modules screen when
+phase 4 lands) and restart. **You never build anything** — the client chunk is prebuilt and the one
+runtime dependency ships inside the tarball.
+
+**Connecting it to a shard.** The module needs the
+[uo-link sidecar](https://gitea.whitlocktech.com/RunicGateway/link) running next to the ServUO
+shard. Deploy that with the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) —
+[`../../installer/INSTALL.md`](../../installer/INSTALL.md) is the operator guide — and paste the four
+values it prints into **Admin → Shard**. The token is encrypted at rest and write-only in the API.
+
+**Nothing requires the shard to exist.** With no sidecar configured the site renders normally and
+shows the shard offline. That is the same bargain the module system makes one level up: a module
+that fails to load never takes the site down.
+
+**Environment variables** — four, all optional, all read by the module and documented in its README:
+`UOLINK_BASE_URL`, `UOLINK_WS_URL`, `UOLINK_PROTOCOL`, `TOWNCRIER_DURATION_SEC`. They live in the
+Compose `.env`, because that is what reaches the container.
+
+## Compatibility
+
+`module.json` declares a `coreApi` semver range, checked at boot against core's
+`MODULE_API_VERSION`. A mismatch fails **loudly** — the module is marked `startup_failed` and the
+site comes up without it. This is a separate number from `PROTOCOL_VERSION`, which versions the
+shard wire and says nothing about a website module.
+
+The module's CI clones core at a **pinned** ref ([`MODULE_API.md`](../../website/MODULE_API.md) §5.3)
+to generate its frozen manifest. Bumping that pin is a deliberate commit that says which core the
+module was last proved against — not a tracking reference that turns core's unrelated changes into
+red Xes here.
diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md
index 59e4ab1..d463eb2 100644
--- a/website/BACKEND_DESIGN.md
+++ b/website/BACKEND_DESIGN.md
@@ -731,8 +731,15 @@ 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.** 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 |
+| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 158 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` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
+
+Both are **core's**. An installed module's routes are in neither: they are in that module's own
+frozen manifest and its `swagger-fragment.json`, in its own repo, and core merges the fragment into
+`/api/docs.json` at request time (§4.0.1). So on a running instance the served document describes
+more than the committed one does, which is the intended arrangement rather than a drift —
+`swagger-output.json` has to regenerate identically on any machine, whatever happens to be
+installed on it.
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
it churns whenever a description is reworded — it documents *intent*. The manifest is introspection-
@@ -764,6 +771,34 @@ authenticated endpoints silently. Names are a hint only — `requireRole(...)` r
arrow and cannot be observed — but a *missing* `requireAuth` is unambiguous, and the server test suite
asserts every `/admin/**` and `/player/**` route still carries it.
+#### 4.0.1 `/api/docs.json` is assembled per request
+
+`GET /api/docs.json` and the Swagger UI at `/api/docs` do not serve `swagger-output.json` directly.
+`swagger/docsSpec.js` merges the `swagger-fragment.json` of every **started** module over it first,
+cached on the module loader's state version and rebuilt when a module's state moves.
+
+It exists because swagger-autogen is static analysis: it parses `src/app.js` as text and follows the
+literal `app.use(…)` chain, which reaches neither an installed module (required by a filesystem loop,
+from a volume that had nothing on it when the image was built) nor an extension slot (whose router is
+created empty by `declareSlot()` and filled later). Slots are handled at generation time by
+`swagger/slotSpecs.js` and are therefore *in* the committed file; modules cannot be, because core
+never has their sources.
+
+Three rules, all from [`MODULE_API.md`](MODULE_API.md) §6.1a:
+
+- **`started` only.** A `registered`, `disabled` or `startup_failed` module's paths are absent —
+ documenting a route that answers 503 or 404 sends a client somewhere it cannot go.
+- **Core wins every key collision**, in all three merged sections (`paths`, `tags`,
+ `components.schemas`); the collision is logged and the module's version dropped. This is what makes
+ the naming rule work: a module namespaces the schemas it *defines* (`UoShardStatus`) and references
+ core's shared ones (`Error`, `ValidationError`) by core's name, and both resolve in the merged
+ document.
+- **A bad fragment costs that module its paths and nothing else.** Missing, unreadable or not JSON is
+ logged and skipped; `/api/docs.json` still answers with everything else.
+
+The committed spec is never mutated — it is a `require()`d JSON module, so an in-place merge would be
+permanent for the life of the process *and* cumulative across rebuilds.
+
### /auth (auth/index.js → the capability routers in §2)
No group gate — `/auth` is where an anonymous caller becomes authenticated. The authenticated parts
diff --git a/website/MODULE_API.md b/website/MODULE_API.md
index 0755c6b..95451bd 100644
--- a/website/MODULE_API.md
+++ b/website/MODULE_API.md
@@ -435,6 +435,33 @@ fragments of started modules into `/api/docs.json`; the full reasoning and the c
§6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered
route has no path in the fragment, and core wins every key collision.
+**Built in phase 3 slice 5** (module-uo#6, website#141). Four things settled while building it, all
+of which a second module inherits:
+
+- **The filename is fixed here, not declared in `module.json`.** `swagger-fragment.json` in the
+ bundle root, like `module.json` itself — so a module cannot point core at some other file, and
+ core's loader has one path to check. A module that ships none is simply absent from the merged
+ document: whether it registered routes without documenting them is the *module's* CI to answer,
+ where the routes are known. Core cannot tell a module with no routes from one that forgot.
+- **Namespace what you DEFINE; reference core's by core's name.** `UoShardStatus` is defined by the
+ module; `#/components/schemas/Error` and `ValidationError` are referenced and **not** redefined.
+ Both resolve in the merged document, which is the only place both halves exist — and shipping a
+ copy of `Error` would be a collision core drops, arriving at the same result the expensive way.
+ This is the practical form of "core wins": it makes the two cases feel different in the source,
+ which is where the mistake would otherwise be invisible.
+- **Generate the fragment from the module's own registrations.** swagger-autogen needs a *file* and
+ cannot follow `api.registerRoutes`, so the module's generator runs its own `register()` against a
+ recording `api` and resolves each router back to its source through `require.cache`. A mount prefix
+ then exists in exactly one place. The two values it cannot derive — the tier base paths and the
+ slot's mount, both §2.4's — are checked against a real core by the §5.3 job rather than trusted.
+- **swagger-autogen reports a broken annotation and then succeeds.** It `console.error`s "Syntax
+ error" or "out of structure", drops that annotation, and prints `Success`. Both repos' generators
+ now capture those diagnostics and fail on them, which found six annotations documenting less than
+ they claimed. Two ways one breaks: an object literal a brace short, and a `"` or backtick inside a
+ single-quoted description (the tool re-quotes both to `'` before evaluating, ending the string
+ early). A third, which nothing but a rendered page catches: an escaped apostrophe survives
+ literally, because the annotation is not evaluated as JavaScript.
+
### 2.9 What core publishes about a module
`GET /api/v1/public/modules` — anonymous, database-free, never site-mode gated.
@@ -1088,6 +1115,29 @@ and runs core's own `routeManifest.js`. Nothing else proves the URLs a module cl
actually serves — a manifest frozen by hand goes stale silently, and the failure it would have caught
is a route that moved.
+**Built in slice 5, and what it does with that core is a SUBTRACTION.** The job generates the
+manifest without the module and then with it; the difference is what the module serves. Filtering the
+combined manifest by the module's prefixes would have answered only "what does the module serve".
+Subtracting also answers **"did core lose anything"** — and a module that shadowed or displaced a
+core route cannot appear as an addition anywhere, so that is the only way to see it. Three checks
+come out of one diff:
+
+1. the added routes match the module's committed `routes.manifest.json`;
+2. **no route of core's was removed or changed**, which is `MODULE_SYSTEM.md` §1.2's promise;
+3. every added route has an operation in `swagger-fragment.json`, and every operation is an added
+ route — §2.8's coverage requirement, answered against a running app rather than against a table.
+
+The third is why this job matters beyond the manifest: everything else in a module's repo compares
+two strings that live in that repo. This compares a URL the module registers against a URL a real
+Express app reports serving, which is the only thing that can catch a fragment that is internally
+consistent and describes nothing.
+
+Also learned here: **copy the module into the core checkout, never symlink it.** The loader filters
+its scan with `entry.isDirectory()`, which reports a link as a link and skips it silently — the
+manifest then comes out with no module routes and the diff looks like a module that registered
+nothing. And the client chunk must be **built before** the copy: `client.entry` is validated during
+the manifest step of the scan, so a missing chunk is a load failure, not a warning.
+
Pinning the ref rather than tracking `edge` is what keeps this from being a source of unexplained red
Xes: core moves for reasons that have nothing to do with the module, and a bump is then a deliberate
commit that says which core the module was last proved against.
@@ -1142,6 +1192,25 @@ shipping one with the same name; the collision is logged and the module's versio
`swagger-output.json` itself stays exactly what core's own routes generate, so `npm run swagger`
remains reproducible on any machine regardless of what is installed.
+Built as `server/swagger/docsSpec.js` (website#141). Three properties that are contract rather than
+implementation, because each one is a way the obvious version is wrong:
+
+- **The committed spec is never mutated.** It is a `require()`d JSON module, so a merge in place
+ would be permanent for the life of the process *and* cumulative across rebuilds — a module's paths
+ outliving its own uninstall. Every rebuild starts from a structural copy.
+- **The Swagger UI is built per request too**, not bound once while `app.js` is still being required.
+ Bound at require time it would show core's routes for the life of the process while
+ `/api/docs.json` showed the merged set — two documents at two URLs, disagreeing.
+- **A bad fragment costs that module its paths and nothing else.** Missing, unreadable or not JSON is
+ logged and skipped; the document still answers. That is §4.4's bargain — one module's failure is
+ never the site's — and a docs page that 500s is strictly worse than one missing a module's routes.
+
+`started` only, matching `clientEntryUrls()` rather than `clientChunks()`: the document is built when
+it is asked for, at which point the state is known, and documenting a module that 503s every one of
+those paths sends a client somewhere it cannot go. The cache key is a new
+`modules.version()` — a counter the loader bumps on every state *change*, which says nothing about
+which module moved or where to.
+
### 6.2 The client contract is much larger than §2.1 says
§2.1 lists three client registration calls and nothing else, implying React and the router are all a
diff --git a/website/MODULE_SYSTEM.md b/website/MODULE_SYSTEM.md
index aa329e6..29fbe7a 100644
--- a/website/MODULE_SYSTEM.md
+++ b/website/MODULE_SYSTEM.md
@@ -730,7 +730,9 @@ navigate a module it does not contain, on a deployment that builds nothing — a
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
+**Phase 3 — Extract `module-uo`. COMPLETE 2026-08-11**, in six slices; the record of each is in
+§2.7.1 and all four acceptance criteria are met (the table at the end of slice 5). Moves out of
+`website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus
`getUser`** (§1.9); the shard-derived half of `notificationStreams.js` and the town-crier leg of
@@ -782,7 +784,7 @@ Each slice is one `module-uo` PR (adds), one `website` PR (deletes), and one `do
| 2 | **Client extension slots** | Core only, and the one slice that adds rather than moves: the client twin of `declareSlot`/`registerExtension` (API §3.7), the `site.footer.status` and `admin.users.detail` slots, and core filling both itself. `module-uo` untouched. |
| 3 | **The whole client half** | **35 files / 5,332 lines** (measured; the 51/~3,700 above was counted differently) — all twelve public pages (`Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`) under `/uo/*`, every admin and player view under `/admin/uo/*` and `/player/uo/*`, `PlayersOnline`, `VendorSales`, `CharacterStats`, `GameAccounts`, the `data/` and `lib/` UO leaves, the public nav rows, the feature provider, and all three slot fills. |
| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `Status`, `Wiki`, `SiteFooter`'s prose, `heroLayout`'s defaults, `brand.js`'s tagline + description, `db/seed.js`'s wiki copy, two user-visible `NavEditor` strings, the comments in `navOverrides.js`, and the 190 dead lines of `api/client.js`'s `shard`/`atlas` namespaces — plus the two settings rows core seeded for module-uo, and the §5.2 CI check that keeps all of it out. `README.md` deliberately deferred to slice 5. |
-| 5 | **Close the phase** | `module-uo`'s frozen route manifest and release workflow; `docs/modules/uo/` and `docs/modules/rust-dryrun.md` |
+| 5 | **Close the phase** | `module-uo`'s frozen route manifest and release workflow; `docs/modules/uo/` and `docs/modules/rust-dryrun.md` — **plus the OpenAPI fragment on both sides**, which §2.8/§6.1a had settled and neither repo had built, and the `README.md` slice 4 deferred |
##### Why the server half cannot be sliced — found 2026-08-11, before writing any of it
@@ -1229,6 +1231,140 @@ learned by getting things wrong first is worth recording:
core's README is a rewrite that belongs with the phase-closing documentation pass rather than
half-done inside a code slice.
+#### Slice 5 — closing the phase, and the obligation nobody had noticed
+
+Module-uo#6 + website#141 + docs#140 (2026-08-11). The slice table words this one as packaging and
+documentation: the module's frozen manifest and release workflow, `docs/modules/uo/` and the
+`module-rust` dry run. It is also where a **contract obligation that had never been built on either
+side** surfaced, and closing it was the larger half.
+
+##### The OpenAPI fragment existed only on paper
+
+[`MODULE_API.md`](MODULE_API.md) §2.8 and §6.1a settle it in detail: a module ships
+`swagger-fragment.json`, core merges the fragments of *started* modules into `/api/docs.json` at
+request time, core wins every collision. **Neither half was written.** Module-uo's 417 `#swagger`
+annotations came across in slice 1 and went nowhere; core's `swagger/mergeSpec.js` named the
+request-time caller in its own file header and that caller did not exist. The result was that the 72
+URLs module-uo serves were **in no OpenAPI spec at all** — this repo's standing rule (never ship a
+route that isn't in the spec) broken by the extraction rather than by a route.
+
+It was folded into this slice rather than deferred to Phase 4, because Phase 3 closing with 72
+undocumented routes would have made the phase's own exit criteria untrue.
+
+**The generator derives its prefixes rather than listing them.** `swaggerFragment.js` runs the
+module's own `register()` against a recording `api` and asks `require.cache` which file each router
+object came from — so a mount prefix lives in `server/index.js` and nowhere else, and swagger-autogen
+(which needs a *file*, and cannot follow `api.registerRoutes`) gets pointed at the right one. The two
+values it cannot derive, the tier base paths and the extension slot's mount, are §2.4's normative
+table — and they are not taken on trust: the frozen-manifest job checks every generated path against
+a real core.
+
+**Schemas are namespaced; core's are referenced by core's name.** The 31 UO schemas moved out of
+core's `swagger.js` as `Uo…`, because core wins a collision and an un-namespaced `ShardStatus` from a
+second game's module would lose to or clobber this one. But the annotations keep pointing at
+`#/components/schemas/Error` and `ValidationError` **without** redefining them: those resolve in the
+merged document, which is the only place both halves exist. Shipping a copy would be a collision core
+correctly drops. Both directions verified against a live merge — 197 paths, no dangling `$ref`.
+
+##### The frozen manifest is a subtraction, not a filter
+
+§5.3 settles that the module's CI clones core at a pinned ref. What it does with that core is the
+design decision this slice made: generate the manifest **without** the module and then **with** it,
+and take the difference.
+
+Filtering the combined manifest by the module's prefixes would have answered "what does the module
+serve". Subtracting answers that *and* "did core lose anything" — and the second is the one §1.2
+promises to the shipped Android app and the Discord bot. A module that shadowed or displaced a core
+route cannot show up as an addition anywhere; it shows up here as a **removal**. Result: 72 routes
+added, **0 removed**.
+
+That job is also the only place in the module's repo where a claim meets ground truth. Everything
+else there compares two strings in the same repository; this compares a URL the module registers
+against a URL a real Express app reports serving, which is what makes the fragment's coverage check
+meaningful rather than self-referential.
+
+##### Releases: the version is declared, not computed
+
+`link` and `installer` both compute the next version from conventional-commit subjects. This module
+does not, because it already has one authoritative version — `module.json`'s, which is what core
+records in `installed_modules`, shows on the admin screen, and sits beside the `coreApi` range a bump
+has to be weighed against. Two sources for one number is how they drift. So: a merge to `main` that
+leaves `module.json` at a version with no release yet publishes one, and bumping the version is an
+ordinary reviewed PR.
+
+The workflow **never writes to a branch** — it tags and publishes — so `main` needs no push
+exception. That is the installer's model, adopted for the reason it was adopted there. The bundle is
+assembled from an **include** list rather than an exclude list, because an exclude list ships
+whatever it forgot.
+
+##### Six dropped annotations across two repos, and the tool that says `Success`
+
+swagger-autogen reports an annotation it cannot parse and **then prints `Success` in green**, having
+skipped it. Nothing was listening in either repo. Making its diagnostics fatal immediately found:
+
+- **core**: `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`, both
+ documented with an **empty request body**, both since the day they were written;
+- **module-uo**: the same brace-short mistake twice more, plus two descriptions whose inner quoting
+ the tool cannot survive — it re-quotes `"` and a backtick to `'` before evaluating, so either
+ inside a single-quoted description ends the string early. A typed, described query parameter had
+ been silently demoted to an untyped one.
+
+This is the same class as §6.1's silent drop, with one difference: the tool did say something.
+
+**And a seventh defect only a browser could show.** Nineteen descriptions carried an escaped
+apostrophe — correct JavaScript, and wrong here, because swagger-autogen does not evaluate the
+annotation as JS. The backslash survived into the fragment and Swagger UI rendered it verbatim to a
+reader, mid-sentence. The fragment was valid JSON, the paths were right, every test passed. Only
+opening the page found it: the §7.7 lesson, in a seam that has nothing to do with the client chunk.
+
+##### The last of core's UO copy
+
+`README.md`'s 48 mentions (the architecture diagram is now core + a module + "the game", and
+*Shard integration (uo-link)* is now *Modules*), the four orphan tags and 31 orphan schemas in
+`swagger.js`, `info.description`'s "a private Ultima Online shard", and `TOWNCRIER_DURATION_SEC` +
+`UOLINK_*` in the two `.env.example`s — module-read, never core-read, and now documented in the
+module's own README rather than half-copied in core's.
+
+Auditing the tag list for the four orphans turned up the same defect pointing the other way: five
+tags **used** by core routes and never declared (`Admin · Email`, `Admin · Invites`,
+`Admin · Moderation`, `Admin · Pages`, `Auth · Me`).
+
+##### The dry run
+
+[`../modules/rust-dryrun.md`](../modules/rust-dryrun.md) — Phase 3's fourth acceptance criterion, and
+the only one that could fail in an interesting way. A written `module-rust`, for a game chosen
+because it shares almost nothing with UO: it **wipes** monthly, a community runs several **servers**
+rather than one shard, its identity is **Steam**, and its server ships **RCON** so there is no
+sidecar to write.
+
+The contract generalises — same manifest, same seven registration calls, same schema-fragment rules,
+same client registry, and **six of the UI kit's seven members** wanted by a game with nothing in
+common with the one the kit was curated from.
+
+It found one real gap: **a module cannot register an identity provider**, and "Sign in with Steam" is
+what a Rust community expects. A Rust module can still ship by copying UO's in-game-code flow, one
+screen worse. Recorded as the first candidate for a future `MODULE_API_VERSION` bump rather than
+bolted on now — an identity provider participates in session creation, which is the one part of core
+a module must never be able to weaken, and §2.7's link-only policy has to survive it.
+
+It also found that two rules written for one reason cover another: §2.6's leading-verb allowlist
+(written because a fragment replays every boot) correctly forbids the wipe-truncation a Rust author
+would put in their schema, and `capabilities` — decoration with one module — is the only thing the
+Android app can ask about a module it has never heard of.
+
+##### Phase 3 is complete
+
+Six slices, 2026-08-11. Core is 158 routes and knows nothing about any game; module-uo is 72 routes,
+27 tables, 40 server files and 35 client files in its own repo, releasable, and documented in
+[`../modules/uo/`](../modules/uo/README.md). Every acceptance criterion in §2.7 is met:
+
+| # | Criterion | Where it is proved |
+|---|---|---|
+| 1 | No UO identifier in core | `npm run check:modules`, first step of server-tests |
+| 2 | Zero internal imports from module into core | `npm run check:imports`, module CI |
+| 3 | Route manifest diff is only the deliberate move | core 158 + module 72, **0 core routes removed** |
+| 4 | A written `module-rust` dry run | [`../modules/rust-dryrun.md`](../modules/rust-dryrun.md) |
+
**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.
@@ -1381,3 +1517,6 @@ row for it — when it has content, not while it is an empty repo.
| 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 |
+| 18 | A module's frozen manifest is the **difference** between a core without it and the same core with it — never a prefix filter | API §5.3 |
+| 19 | A module's release version is **declared** in `module.json`, not computed from commit subjects; the workflow tags and publishes and never writes to a branch | §2.7.1 |
+| 20 | A module namespaces the schemas it **defines** and references core's shared ones by core's name | API §2.8, §6.1a |
diff --git a/website/website-README.md b/website/website-README.md
index 31c7fe1..0d21c09 100644
--- a/website/website-README.md
+++ b/website/website-README.md
@@ -1,18 +1,28 @@
# Runic Gateway Website
-Public site, wiki, and protected admin panel for a private Ultima Online shard — a
-full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
-[Branding](#branding)); **UOMysticmoon** is the first instance.
+[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
+[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
+[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
+[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
+[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
+[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
+[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
+
+Public site, wiki, and protected admin panel for a game community — a full-stack app
+in one repo. Everything specific to a *particular* game lives in an installable
+module, not here. Branding is instance-configurable via `BRAND_*` (see
+[Branding](#branding)); **UOMysticmoon**, an Ultima Online shard, is the first
+instance, and its game half is
+[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo).
A full-stack app in one repo:
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
-- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
-- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
-- **Moderation appeals** — a player whose linked Discord identity was banned or muted (per the bot's `mod_actions` log) can open an appeal from the player portal; staff claim and resolve appeals from an admin queue, and approving a ban/mute appeal best-effort reverses it in Discord automatically. See [MODERATION_APPEALS.md](MODERATION_APPEALS.md).
+- **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production.
+- **Modules** — the game-specific half of a site is a module dropped onto a volume: it adds routes, database tables, nav entries and whole SPA pages without this repo knowing anything about the game. See [Modules](#modules).
-The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
+The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
---
@@ -30,20 +40,19 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
- [Pages & routes](#pages--routes)
- [API endpoints](#api-endpoints)
- [API documentation (Swagger)](#api-documentation-swagger)
-- [Shard integration (uo-link)](#shard-integration-uo-link)
+- [Modules](#modules)
- [Environment variables](#environment-variables)
- [Security](#security)
- [Logging](#logging)
-- [Deployment behind Pangolin](#deployment-behind-pangolin)
+- [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)
---
## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
-(`router → controller → model → db`), which persists to MariaDB and bridges to the live
-game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
-See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up.
+(`router → controller → model → db`), which persists to MariaDB. Anything that knows
+what game this site is about lives in an installed module, on the right of the diagram.
```mermaid
flowchart TB
@@ -63,32 +72,29 @@ flowchart TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware
helmet · siteMode · noindex
rateLimit · loginProtection · botScore · validate"]
- router["Router /api/v1
auth (web · mobile · sso) · public · admin"]
+ router["Router /api/v1
auth (web · mobile · sso) · public · admin · player"]
ctrl["Controllers"]
auth["Session layer (auth/)
sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)
raw parameterized SQL — no ORM"]
sse["SSE fan-out
public stream (allowlist) · admin stream (sensitive)"]
-
- subgraph shardutil["Shard integration (utils/)"]
- ingest["shardIngest.js
WS ingest dispatcher"]
- restcli["uoLinkClient.js
REST client (never throws)"]
- end
-
+ loader["modules/loader.js
scans the volume · mounts · registries · lifecycle"]
secret["secretBox.js
AES-256-GCM secrets at rest"]
end
bot["bot/
Discord bot"]
end
- db[("MariaDB
users · posts · wiki · settings · activity
mobileSessions · authProviders · userIdentities
uoLinkConfig · shard_online/economy/houses/events")]
+ db[("MariaDB
users · posts · wiki · settings · activity
mobileSessions · authProviders · userIdentities
installed_modules · <module>_*")]
- %% ---------- Shard side ----------
- subgraph shardside["Game shard (never internet-facing)"]
+ %% ---------- Module side ----------
+ subgraph modside["modules/<id>/ — installed, not built (e.g. Module-uo)"]
direction TB
- sidecar["uo-link sidecar
(Rust) — the only bridge exposed"]
- servuo["ServUO shard
(C# plugin)"]
+ modsrv["server/ — routers, models, schema fragment
reaches core only through ctx"]
+ modcli["client/dist/entry.js — prebuilt ESM chunk
React shared via window.__rg"]
end
+ game["The game
whatever the module talks to
(for Module-uo: a ServUO shard,
via the uo-link sidecar)"]
+
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
@@ -98,40 +104,42 @@ flowchart TB
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
- ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
- restcli -. reads config/token .-> secret
- ingest --> model
- ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
- restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
- sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
- servuo -->|"loopback TCP 127.0.0.1:7788
newline-delimited JSON (shard dials out)"| sidecar
+ loader -->|"mounts under /api/v1/<tier>/<prefix>"| router
+ loader -->|"require() + register(ctx, api)"| modsrv
+ modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
+ modsrv <--> game
+ browser -->|"<script type=module> injected by htmlShell"| modcli
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
- classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
- class idp,discord ext;
+ classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8;
+ class idp,discord,game ext;
class db store;
- class sidecar,servuo bridge;
+ class modsrv,modcli mod;
```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer.
-- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
- sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
- (`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
-- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
- a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
+- **Core knows nothing about any game.** Routes, tables, nav entries, SPA pages and push streams for
+ a specific game arrive from a module the operator installed. Core provides the seams; the module
+ fills them. See [Modules](#modules).
+- **A module that fails must never take the site down.** The loader catches failures across a
+ module's whole lifecycle and marks that one module `startup_failed`; the site comes up with its
+ routes and nav absent, and the admin panel says why.
+- **Sensitive events stay private.** Events fan out to browsers over two SSE channels — a public
+ allowlist stream and an admin-only stream that adds staff audit / cheat / login events. Which
+ event kinds are public is decided by the module that publishes them, and core enforces the split.
---
@@ -145,25 +153,26 @@ flowchart TB
| Frontend | React 18, Vite 5, React Router 6 |
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
-| Deploy | Docker Compose, Pangolin reverse proxy |
+| Deploy | Docker Compose, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
---
## Project structure
```
-UOMSITE/
+website/
├─ server/ Express API
│ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
-│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
-│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
+│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin / player route groups
+│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities · modules (.model + .db)
+│ │ ├─ modules/ loader (scan · validate · mount) · registries (the seams) · lifecycle (boot/shutdown + reconcile)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
-│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
+│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger · htmlShell
│ ├─ db/ schema.sql + seed.js
-│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
+│ ├─ swagger/ swagger.js (generator config) · swagger-output.json (generated, core only) · docsSpec.js (merges module fragments at request time)
│ └─ .env.example
├─ client/ React + Vite SPA
│ ├─ src/
@@ -172,9 +181,11 @@ UOMSITE/
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
│ │ ├─ contexts/ AuthContext, SiteContext
+│ │ ├─ modules/ the client registry: routes · nav · slots · feature gates · window.__rg
│ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens
│ └─ public/assets/img/ hero image
+├─ modules/ installed modules, one directory each — a Docker bind mount; empty here
├─ Dockerfile builds client → serves via Express
├─ docker-compose.yml app + MariaDB
├─ .env.example root env (used by Compose)
@@ -216,6 +227,10 @@ IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
- Health check: `GET http://localhost:3000/api/health` → `{ "status": "ok" }`
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
+- Modules: installed into `./modules` on the host (bind-mounted to `/app/modules`), never baked into
+ the image — an operator adds one to a pull-only deployment without building anything. Adding or
+ removing one takes a `docker compose restart app`; the scan is synchronous at startup. See
+ [`modules/README.md`](modules/README.md).
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back:
@@ -296,7 +311,7 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/site/screenshots` | Screenshot gallery |
| `/site/five-on-friday` | Five on Friday |
| `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue |
-| `/site/about` · `/site/status` | About · Shard status |
+| `/site/about` · `/site/status` | About · Site status |
| `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) |
**Admin** (cookie auth, `noindex`):
@@ -314,6 +329,13 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/admin/users` | User management |
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
+**Player** (any signed-in account, `noindex`): `/player` and its self-service views. Staff are a
+superset of players and reach these too.
+
+An installed module adds its own pages under `//*`, `/admin//*` and `/player//*` — for
+Module-uo that is `/uo/shard`, `/admin/uo/link`, `/player/uo/characters` and the rest. Core does not
+know their names; they arrive with the module and are interleaved into the nav.
+
---
## API endpoints
@@ -325,13 +347,19 @@ npm start # node server → serves API + SPA at http://localhost:3
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
-| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
-| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
-| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
+| Player | `/api/v1/player` (`me`, credentials, 2FA, identities, appeals) | cookie/bearer (any signed-in account) |
+| Modules | `/api/v1/public/modules` — id, name, version and capabilities of the modules currently serving | none |
+
+**Module routes are not in this table**, because they are not core's. An installed module mounts
+under `/api/v1/public/`, `/api/v1/admin/` and `/api/v1/player/`; which
+prefixes exist depends on what is installed. Module-uo, for instance, serves 72 routes under
+`/shard`, `/atlas` and `/uo-link` — see its own
+[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json).
+On a running instance, `/api/docs` lists everything, core and modules together.
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
-See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
+See [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
docs below for a per-endpoint reference (parameters, request bodies, response codes).
---
@@ -367,88 +395,132 @@ cd server
npm run swagger # → server/swagger/swagger-output.json
```
-`swagger.js` post-processes the generator's output in two ways before writing it:
-
-- **Trailing slashes are stripped from path keys.** swagger-autogen builds a path by
- string-concatenating the mount prefix with the route argument, so a capability router mounted at
- `/users` whose collection route is `router.get('/')` would document as `/api/v1/admin/users/` —
- a URL no client calls, while dropping the one they all do. Express is indifferent (non-strict
- routing treats the two as one route), but the published spec is a contract.
-- **Path keys are sorted.** The generator emits them in router-traversal order, so moving a route
- between files rewrote most of this ~5k-line committed artifact even when the API was provably
- unchanged. Sorting keeps the diff proportional to the change. OpenAPI attaches no meaning to path
- order, and `scripts/routeManifest.js` already sorts for the same reason.
-
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash).
+**The committed spec is core only, and the served one is not.** swagger-autogen is *static
+analysis* — it parses `src/app.js` as text and follows the literal `app.use(…)` chain — so it can
+see neither an installed module (which arrives on a volume long after the image was built, and
+mounts through a call no parser can follow) nor an extension slot (whose router is created empty and
+filled later). Both are handled by merging a **fragment**:
+
+- **Extension slots** contribute at generation time, from `server/swagger/slotSpecs.js`, so they are
+ in the committed file.
+- **Modules** contribute at request time, from the `swagger-fragment.json` each one ships, merged by
+ `server/swagger/docsSpec.js`. So `/api/docs.json` on a running instance describes more than
+ `npm run swagger` produces here, and `swagger-output.json` stays reproducible on any machine
+ regardless of what is installed.
+
+**Core wins every key collision** — a module cannot redefine a core path, tag or schema by shipping
+one with the same name; the collision is logged and the module's version dropped.
+
+One thing worth knowing if you edit an annotation: swagger-autogen **reports a broken one and then
+succeeds anyway**, dropping it. `npm run swagger` now captures those diagnostics and fails, which is
+how two annotations that had been silently documenting an empty request body were found. If it
+rejects yours, the usual causes are an object literal a brace short, or a `"` or backtick inside a
+single-quoted description (it re-quotes both to `'` before evaluating).
+
+### The route manifest (frozen URL surface)
+
+`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
+Express listeners actually expose. It is **not** documentation — it is the machine-checkable freeze of
+the URL surface, so that carving the router files up by business capability
+(`docs/website/API_V2_PLAN.md`) can be proved to move no URL instead of merely claiming it.
+
+```bash
+cd server
+npm run routes:manifest # → routes.manifest.json + routes.guards.json
+npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
+```
+
+The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
+sits on the line *after* `router.get(`, which defeats greps) and keeps only
+`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand`
+and installed modules' `/modules/` chunks are filesystem-conditional static mounts, not API
+contract, so they are excluded and the output depends neither on whether the client has been built
+nor on which modules are mounted.
+
+Two generated files, two very different meanings:
+
+| File | Meaning of a diff |
+|---|---|
+| `routes.manifest.json` | **Contract change.** A URL moved. Justify it in the PR description; never let one ride along in a "mechanical" refactor. |
+| `routes.guards.json` | **Review aid.** Per route: handler count + the *named* middleware on its mount chain. Names are a hint only — `requireRole(...)` returns an anonymous arrow and cannot be seen — but a vanished `requireAuth` is unambiguous. |
+
+Unlike the Swagger spec, the manifest is annotation-free: `swagger-output.json` documents intent (only
+annotated routes appear), the manifest records reality.
+
---
-## Shard integration (uo-link)
+## Modules
-The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
-runs next to the ServUO shard. Its source lives in a separate repo:
-**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
-exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
-itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
-to it.
+**Everything specific to a game is a module.** Core has no idea what an "account", a "character" or
+a "shard" is; it provides seams, and a module fills them. That is what makes one image able to run a
+site for any game rather than for Ultima Online in particular.
-### How it works
+The design of record is
+[MODULE_SYSTEM.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md);
+the normative contract — the one to read before writing a module — is
+[MODULE_API.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md).
+The worked example is [RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo),
+which is where everything this README used to describe under *Shard integration (uo-link)* now
+lives: the sidecar client, the ingest dispatcher, account linking, the town crier, the spawn atlas,
+and every page that renders them.
+
+### An operator never builds anything
+
+That constraint shapes the whole design. Installing a module is the WordPress-plugin experience — an
+admin-panel action, or a directory dropped onto the `modules/` volume — because production runs a
+prebuilt, pull-only image with no toolchain in it. So a module ships **assembled**: its client half
+is a prebuilt ESM chunk that resolves React from a `window.__rg` global core owns (an import map
+would have to be inline, and the CSP is `script-src 'self'`), and its one runtime dependency travels
+inside the tarball.
```
-ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
- REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
+modules/
+└─ uo/ one directory per module; the id is the directory name
+ ├─ module.json id, version, coreApi range, mounts, extensions, capabilities
+ ├─ swagger-fragment.json merged into /api/docs.json while the module is running
+ ├─ server/ routers, models, and an idempotent schema.sql fragment
+ └─ client/dist/entry.js the prebuilt chunk, injected by utils/htmlShell.js
```
-- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
- token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
- **Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
- the API — it is never returned to any client and never sent to the browser. Every call the backend
- makes carries `Authorization: Bearer ` and an `X-UOLink-Version` header (a protocol
- mismatch fails fast with `409` instead of being mis-parsed).
-- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
- and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
- `vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
- `link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
- each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
- kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
- ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
- restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
- the sidecar's `/history`.
-- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
- `/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
- commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
- throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
- degrades to a `503`/retry banner instead of a 500.
-- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
- Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
- **admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
- attempts, IPs). Sensitive kinds can never leak onto the public channel.
+`modules/` is a bind mount in `docker-compose.yml`, so placing a directory there by hand is a
+supported install. The directory is tracked in git (via its README) on purpose: Docker recreates a
+*missing* bind-mount source as `root:root`, and the container is uid 1000.
-### Account linking
+### What a module gets, and what it may not do
-A player (or staff member) proves ownership of a game account without sharing any game credentials:
+At boot, `app.js` scans the volume synchronously, validates each `module.json`, and calls the
+module's `register(ctx, api)`:
-1. In game, the player runs **`[link`** and receives a one-time code.
-2. On the website (Player portal, or Admin → Account for staff) they enter the code.
-3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the
- game account with the website user id, and mirrors the link locally in `shard_account_links`.
+- **`ctx` is everything core hands over** — the database, the logger, settings, the session reader,
+ push, the secret box, the middleware, the rate-limit factory, the activity log, and **express
+ itself**. A module lives outside `server/`, so Node's resolver never reaches core's
+ `node_modules`; anything it must share has to be handed to it, or there would be two Expresses and
+ two Reacts in one process.
+- **`api` is everything it may register** — routes (one prefix per tier), an extension slot fill,
+ notification streams, a news-announce leg, a post hook, and `onBoot`/`onShutdown`.
+- **It may not reach into core's tree**, mount outside its declared prefixes, or create tables
+ outside its `_` prefix. Each of those is checked, in the module's CI and again by the loader.
-That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
-are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
-character**; players and editor/moderator staff are limited to their own linked accounts.
+Two things are guaranteed regardless of what a module does. **A failure never takes the site down**:
+the loader catches everything from `require` to `onBoot`, marks that module `startup_failed`, and
+the site comes up with its routes and nav absent and the reason on the admin screen. And **no URL of
+core's may move** — a module that displaced one is caught by the frozen route manifest, which is
+generated from a real core with the module loaded.
-### What each audience sees
+### What is running right now
-| Surface | Endpoints | Who | Data |
-|---|---|---|---|
-| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown by name. Their in-game **map location is only included for admin/moderator viewers** — for players and the public it is stripped from the payload entirely (server-enforced, not just hidden in the UI). Linked *players* are never listed publicly; no vitals or account are exposed. |
-| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
-| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
+```
+GET /api/v1/public/modules
+{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "0.3.0",
+ "capabilities": ["shard", "atlas", "market", …] } ] }
+```
-The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the
-shard is offline), every shard surface degrades gracefully — the public page still renders, showing
-the shard as offline.
+Anonymous, database-free, never site-mode gated, and **`started` modules only** — a module that is
+disabled or failed is absent, exactly as its routes and its nav already are. Clients feature-detect
+against it; they do not use it to decide what to load (the HTML shell injects each chunk's tag).
---
@@ -461,12 +533,13 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `NODE_ENV` | `production` | |
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
+| `MODULES_DIR` | `/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) |
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
-| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
+| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + proxy HTTPS) |
| `COOKIE_NAME` | `rg_token` | changing it on a live instance invalidates existing sessions |
| `BRAND_*` | Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see [Branding](#branding) |
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
@@ -482,15 +555,14 @@ 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` / `/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
-| `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`) |
+| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for legs that are due or retrying. Which legs exist is up to what has registered one — Discord is core's; a module may add its own |
---
## Branding
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
-image can run as any shard. With none set, everything renders as **Runic Gateway**.
+image can run as any community. With none set, everything renders as **Runic Gateway**.
| Var | What |
|---|---|
@@ -507,7 +579,7 @@ API (`SiteContext`), so no rebuild is needed; the server templates `index.html`
settings override `BRAND_NAME` / `BRAND_CONTACT_EMAIL` when set. Image assets are
delivered from the `./brand` bind-mount (see `brand/README.md`).
-**UOMysticmoon** is the first instance — [`.env.uomysticmoon.example`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/.env.uomysticmoon.example)
+**UOMysticmoon** is the first instance — [`.env.uomysticmoon.example`](.env.uomysticmoon.example)
holds the exact `BRAND_*` + infra (`DB_NAME`/`DB_USER`/`COOKIE_NAME`) pinning to
run this repo as UOMysticmoon.
@@ -571,7 +643,7 @@ run this repo as UOMysticmoon.
**Platform**
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
- behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
+ behind a reverse proxy (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
contact form falls back to a `mailto:` link when unconfigured.
@@ -598,10 +670,60 @@ bind-mounted to `./logs/app.log` and `docker compose logs -f app` shows the cons
---
-## Deployment behind Pangolin
+## Deployment behind a reverse proxy
`docker compose up -d --build` exposes the `app` container on `0.0.0.0:3000` (no `127.0.0.1`
-binding) so Pangolin can reach it. Point a Pangolin resource at `app:3000`. Because `COOKIE_SECURE`
-defaults to `auto`, the admin login works both directly via the LAN IP over HTTP **and** through
-Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network
-(no published port by default); data persists in the `dbdata` volume, uploads in `uploads`.
+binding) so a reverse proxy — Pangolin, Nginx, Caddy, Traefik, etc. — can reach it. Point the
+proxy at `app:3000` (or the host's `:3000` if the proxy runs outside Compose) and terminate TLS
+there. Because `COOKIE_SECURE` defaults to `auto`, the admin login works both directly via the
+LAN IP over HTTP **and** through the proxy over HTTPS — no config change needed. MariaDB stays on
+the private Compose network (no published port by default); data persists in the `dbdata` volume,
+uploads in `uploads`.
+
+Set `TRUST_PROXY` so Express reads the real client IP from the proxy's `X-Forwarded-For` header
+(see [Environment variables](#environment-variables)) — required for rate limiting, bot scoring,
+and correct logging. Forward the standard `X-Forwarded-For` and `X-Forwarded-Proto` headers from
+your proxy.
+
+Minimal proxy examples:
+
+```nginx
+# Nginx
+location / {
+ proxy_pass http://app:3000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+}
+```
+
+```caddy
+# Caddy — Caddyfile (automatic HTTPS; forwards X-Forwarded-* by default)
+your.domain {
+ reverse_proxy app:3000
+}
+```
+
+**Pangolin:** create a resource targeting `app:3000`; it forwards the required headers and
+terminates HTTPS out of the box, so no extra configuration is needed.
+
+---
+
+## License
+
+Runic Gateway is free software, licensed under the **GNU General Public License
+v3.0 or later** — see [LICENSE.md](LICENSE.md).
+
+ Copyright (C) 2026 Runic Gateway
+
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU General Public License as published by the Free Software
+ Foundation, either version 3 of the License, or (at your option) any later
+ version. It is distributed WITHOUT ANY WARRANTY; without even the implied
+ warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ General Public License for more details.
+
+Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) (note
+the **AI-usage disclosure** requirement) and our
+[Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately per
+[SECURITY.md](SECURITY.md).