docs(builder): phase 8 — modules, architecture and reference
All checks were successful
PR checks / checks (pull_request) Successful in 1m13s

Twenty pages completing the tree section 10 planned: Modules (8), Architecture
(5) and Reference (7). Four decisions, D38-D41, recorded in PLAN.md section 10.

D39 is the one that shaped the phase. Section 1 forbids re-specifying a
contract, and a Reference section is exactly where that rule is most tempting to
break, so the line is drawn at names: every environment variable, config key,
installer command, visibility rung and canonical document is listed with one
terse line saying what it is FOR, while shapes, semantics and every "why" stay
in the canonical document.

That is only safe because the names are checked. checkReference.mjs compares six
enumerations against the repositories that own them, over the Gitea API, as set
comparisons in BOTH directions -- and the second direction is the one that earns
its keep, because a reference page does not usually rot by describing something
that vanished, it rots by quietly not mentioning what was added since.

The check went green on its first run, which is the least trustworthy possible
outcome, so it was verified by breaking it: seven mutations, all caught. The one
worth keeping is the visibility ladder REORDERED with its membership unchanged
-- it is a security boundary, and a set comparison alone would have passed it.

D41 turns plannedSidebar from a checklist into a checked invariant, and finding
out why was the phase's first defect: it had already drifted, because phase 7
added the Content page under D37 and never updated the list. Nothing failed,
because nothing read it. checkSidebar.mjs now asserts the two trees agree on
groups, labels and order -- order because the order of Getting started IS the
installation path.

Two more things the writing found. PLAN.md's page count was wrong and had been
since section 10 was written ("roughly 38, 37 planned" for a tree of forty).
And module.json's `mounts` and the SPA's paths are different mechanisms that no
single document stated plainly -- module-uo declares admin: ["/shard",
"/uo-link"] while its screen lives at /admin/uo/link, because API routes are
deliberately NOT namespaced while SPA routes are. That is precisely the
distinction the installer got wrong in v0.1.0, and it now has a named home.

D40: the docs link to /architecture/'s drawn diagrams rather than importing
them. Those components carry marketing chrome and depend on diagram.css, which
Starlight does not load; the docs use text diagrams, which paste into an issue.

npm run verify green: 40 pages across 5 groups agree with plannedSidebar, 2390
internal links resolve, 123 repository links point at a branch, 19 facts, 59
quickstart checks, 22 reference enumerations, astro check 0 errors, 36 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:17:31 -05:00
parent e8cb6061fe
commit d89ce06bb8
27 changed files with 2794 additions and 8 deletions

View File

@@ -44,12 +44,54 @@ export const docsSidebar = [
{ label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' },
],
},
{
label: 'Modules',
items: [
{ label: 'The module system', slug: 'docs/modules/the-module-system' },
{ label: 'Installing modules', slug: 'docs/modules/installing-modules' },
{ label: 'Module lifecycle', slug: 'docs/modules/module-lifecycle' },
{ label: 'The module manifest', slug: 'docs/modules/the-module-manifest' },
{ label: 'The module API', slug: 'docs/modules/the-module-api' },
{ label: 'Building a module', slug: 'docs/modules/building-a-module' },
{ label: 'The Integration Kit', slug: 'docs/modules/the-integration-kit' },
{ label: 'Testing and release', slug: 'docs/modules/testing-and-release' },
],
},
{
label: 'Architecture',
items: [
{ label: 'System architecture', slug: 'docs/architecture/system-architecture' },
{ label: 'The bridge', slug: 'docs/architecture/the-bridge' },
{ label: 'Authentication architecture', slug: 'docs/architecture/authentication-architecture' },
{ label: 'Teams architecture', slug: 'docs/architecture/teams-architecture' },
{ label: 'Protocol versions', slug: 'docs/architecture/protocol-versions' },
],
},
{
label: 'Reference',
items: [
{ label: 'Environment variables', slug: 'docs/reference/environment-variables' },
{ label: 'Installer CLI', slug: 'docs/reference/installer-cli' },
{ label: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' },
{ label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' },
{ label: 'HTTP API', slug: 'docs/reference/http-api' },
{ label: 'Event catalog', slug: 'docs/reference/event-catalog' },
{ label: 'Canonical documents', slug: 'docs/reference/canonical-documents' },
],
},
];
/**
* The full planned tree, kept next to the live sidebar so phases 7 and 8 have their
* checklist in the place they will be working. Not exported into the Starlight config —
* it names pages that do not exist yet.
* The tree §10 planned, kept as the record of what was intended — every page it names now
* exists, as of phase 8.
*
* It was the phases 7/8 checklist, and a checklist with nothing left on it is no longer
* pulling its weight: it is a second copy of the tree above, maintained by hand, and it had
* already drifted once (phase 7 added `Content` under D37 and this list was not updated,
* which nothing caught because nothing reads it). `checkSidebar.mjs` now asserts the two
* agree, which is what makes keeping it safe.
*
* Not exported into the Starlight config.
*/
export const plannedSidebar = {
'Getting started': [
@@ -65,6 +107,7 @@ export const plannedSidebar = {
'Configuration',
'Branding and theming',
'Navigation and pages',
'Content',
'Users and roles',
'Authentication',
'Teams',

View File

@@ -0,0 +1,123 @@
---
title: Authentication architecture
description: One session model behind three very different front doors — cookies, bearer tokens and SSO — and where the boundaries actually are.
---
import { Aside } from '@astrojs/starlight/components';
The administrator's view of this is
[Authentication](/docs/administration/authentication/). This is how it is built.
## One session service, three surfaces
The governing decision: **there is a single source of truth for sessions**, and every
authentication surface produces the *same* session model.
```
browser native app SSO provider
(httpOnly JWT) (bearer + refresh) (OAuth2 / OIDC + PKCE)
│ │ │
└───────────────────┼────────────────────────┘
sessionService
createSession(user, authMethod)
validateSession()
```
Controllers call `createSession`; middleware calls `validateSession`. Nothing invents its
own notion of "logged in".
That matters more than it sounds. Three front doors with three session implementations is
three places for an authorization bug to hide, and the one that gets least attention is the
one that gets exploited.
<Aside type="note" title="`utils/auth.js` is a facade">
It exists for backward compatibility and is a thin wrapper. New work goes through the
session service.
</Aside>
## The three surfaces
**Web** — a JWT signed with `JWT_SECRET`, carried in an `httpOnly`, `sameSite=Lax` cookie.
`secure` is decided **per request** (`COOKIE_SECURE=auto` → `secure: req.secure`), which is
what lets one deployment work both over HTTPS through a proxy and over plain HTTP on a LAN
address.
**Mobile** — short-lived bearer access tokens plus **rotated, hashed, revocable** refresh
tokens. Hashed server-side, so a database disclosure does not hand over live sessions.
**SSO** — Google, Discord or a custom OIDC provider, PKCE-guarded.
## SSO is link-only, by policy
**An external identity must already be linked to an existing account.** Identities are
never auto-provisioned.
This is a deliberate policy rather than an unimplemented feature. Auto-provisioning turns
"anyone with a Google account" into "anyone with an account here", which is not a decision
a site operator should make by installing an OAuth client.
## Admin is re-validated every request
Roles are **re-checked against the database on every admin request**, not trusted from the
token.
The consequence is the point: a demoted user loses access **at once**, rather than when
their token happens to expire. A stateless JWT that carried the role would keep asserting it
for up to a day.
## Trusted devices gate the second factor only
A second, separate httpOnly cookie (`rg_trust`, 30 days by default) lets a browser or app
**skip the TOTP step** on future logins — **never the password**.
Four properties, each chosen:
- It is **opaque and sha256-hashed server-side**, stored in a table. It is not a JWT claim,
so the stateless session token is unchanged.
- It is **per-row revocable**, from the admin panel or by the user.
- It **deliberately outlives logout.** Logging out ends a session; it does not make the
device untrusted, because the device is still the same device.
- It is **cleared** on untrust, password change, password reset, or disabling TOTP.
**Recovery codes** (bcrypt, single-use) are the lockout fallback. Every trusted-device and
MFA action is audit-logged.
## The login-hardening layer
Bot scoring with automatic IP banning, TOTP 2FA, a honeypot field, and rate limiting with
backoff. The admin *Bot Activity* panel is deliberately **read plus emergency-unban only** —
it is a window onto an automatic system, not a control surface for it.
## Where core's boundaries stop
Core's security boundaries end at **authentication, roles and the session**.
A module that serves game data brings its **own** audience rules, and core does not police
them beyond the gates it hands over — `requireAuth`, `requireRole`, and the tier group
gates. See [The module API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
`module-uo`'s is the worked example, and it is a real boundary rather than a convenience
filter: an admin-configurable, per-feature and per-field audience ladder with **fail-closed
defaults**, applied at routes, at SSE subscribe time, *and* at the navigation. All three,
because a surface that is filtered in only two of those places leaks through the third.
## Content Security Policy
`script-src 'self'` with **no inline script**, which is why [module chunks are served
same-origin](/docs/modules/building-a-module/) and why an import map was never an option.
`form-action 'self'` is pinned explicitly rather than inherited, because it blocks an
injected form POSTing credentials off-origin — an exfiltration path `connect-src` does not
cover.
Violation reports go to a **same-origin** sink that stores nothing: reports describe attacks
against this site and are not handed to a third-party collector. It parses both wire formats
(browsers disagree), and always answers `204` even for malformed input — a `4xx` would make
the error handler log attacker-supplied bodies and turn an open endpoint into a log-flood
primitive.
## Canonical document
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
§6 is normative for everything on this page.

View File

@@ -0,0 +1,100 @@
---
title: Protocol versions
description: One number, declared in three repositories, that decides whether a shard and a sidecar are allowed to talk to each other.
---
import { Aside } from '@astrojs/starlight/components';
The loopback wire protocol between the game plugin and the sidecar is a **versioned
compatibility contract**, not a build dependency. Nothing compiles the three sides together,
so the number is what stops a mismatch from being discovered as corrupted data.
The current protocol is **4**.
## Three declaration sites
The same number is written down in three places, and they must move together.
| Where | What declares it |
|---|---|
| `link/sidecar/src/main.rs` | `pub const PROTOCOL_VERSION: u32 = 4` — what the sidecar speaks |
| `servuo-plugins/overlay.toml` | `protocol = 4` — what the plugin overlay speaks |
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
<Aside type="caution" title="Bump the overlay in the same PR as the emitters">
CI folds `overlay.toml` into the release manifest, and **the installer refuses to pair an
overlay and a sidecar whose protocol numbers disagree**.
A bump that lands separately from the emitters does not fail loudly — it silently fails to
compose into a bundle, and the next release simply does not appear.
</Aside>
## How a mismatch is caught
Two independent mechanisms, at two different boundaries.
**Sidecar ↔ website.** Every sidecar response carries `X-UOLink-Version`. A mismatch is
rejected with **`409`** rather than mis-parsed. The website's protocol expectation is
admin-managed, alongside the base URL and token, on the shard configuration screen.
**Overlay ↔ sidecar.** The installer resolves a **bundle** — an exact, protocol-checked
sidecar and overlay pair published by CI — and never "latest of each". That is the whole
reason bundles exist: two independently released components that must agree cannot be
allowed to be chosen independently.
## What a bump obliges
Changing a message shape means editing every side plus the specification. A protocol-4
change touched:
| Repository | What had to change |
|---|---|
| `servuo-plugins` | The emitters, the config keys, and `overlay.toml` |
| `link` | `PROTOCOL_VERSION`, a store migration, and the projections |
| `module-uo` | The tables, the ingest, and the kind-to-feature map |
| `docs` | The protocol document and the integration guide |
Note `link`'s entry: **a protocol bump can require a store migration**, because the sidecar
persists what it forwards. That is not automatic, and version 4 was the first bump that
needed one.
## This is not the module API version
Two different numbers, versioning two different contracts, and confusing them is easy.
| | Versions | Lives in | Checked |
|---|---|---|---|
| **`PROTOCOL_VERSION`** | The game ↔ sidecar wire | `link`, `servuo-plugins`, the bundle | `X-UOLink-Version`, and the installer's pairing check |
| **`MODULE_API_VERSION`** | The website ↔ module contract | `website`, and every module's `coreApi` | At module load, before the module's code runs |
A module that never talks to a game server has no protocol version at all. See [The module
manifest](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means).
## When a contract owes a bump
The rule this project settled on: **a contract owes a bump only once it has landed on
`main`.**
While a version has only ever existed on a development branch, additions join it in place
rather than forcing a new number. Once it has shipped, it is somebody else's dependency and
a change to it is a change to a published contract.
## If you are building a bridge for another game
You do not inherit this protocol — you define your own between your plugin and your sidecar.
What is worth inheriting is the **shape**:
- Declare the version on both sides, in files a release can read.
- Make a released pair carry its own compatibility claim, so a deployment tool can refuse a
bad combination rather than discovering it at runtime.
- Reject a mismatch **loudly and early**. A `409` is a good outcome; a successful parse of a
message you did not expect is not.
## Canonical documents
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
is the protocol-4 record, including its cross-repository obligations;
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
§7 is the wire protocol, and
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
the integration guide.

View File

@@ -0,0 +1,141 @@
---
title: System architecture
description: The whole platform in one place — what each repository is, what talks to what, and the invariants that hold across all of them.
---
import { Aside } from '@astrojs/starlight/components';
The drawn version of this, for evaluators, is on
[Architecture](/architecture/). This page is the detailed account.
## Ten repositories, deployed independently
Nothing here is a monorepo. Each repository has its own history, its own CI and its own
release cadence; what binds them is a set of **versioned contracts**, not a build.
| Repository | What it is |
|---|---|
| `website` | The Node/Express + MariaDB + React site. The only internet-facing web app |
| `Module-uo` | All the *Ultima Online* code, installed into the site as a module |
| `link` | The **uo-link sidecar**, in Rust — the only network-facing bridge component |
| `servuo-plugins` | The in-game plugin, C#, that feeds the sidecar |
| `installer` | Deploys the shard side: sidecar plus plugin overlay |
| `Android-app` | Native Android client of the website API |
| `Integration-kit` | The instruction book for putting a different game on the platform |
| `docs` | Canonical design docs and the protocol spec |
| `runicgateway.com` | This site |
| `.profile` | The organisation landing page |
## The layers
```
Browser (React SPA) Native Android app
│ cookie │ bearer
└──────────┬─────────────────┘
┌────────────────────────┐
│ website (Node) │
│ middleware → router │
│ → controller → model │
│ → db │
└───────┬────────────┬───┘
│ │ loads at boot
▼ ▼
MariaDB modules/<id>/ ← installed, never built
the game, via whatever
bridge that module owns
```
The backend is strictly layered — `middleware → router → controller → model → db` — with
models in `.model.js` (logic) and `.db.js` (SQL) pairs, and **raw parameterised queries with
no ORM anywhere**.
## Core is game-agnostic
Since the module system shipped on **2026-08-12**, nothing in core knows about any
particular game. Routes, tables, pages, navigation and push streams for a game arrive from
[a module](/docs/modules/the-module-system/) the operator installed. Core provides the seams;
the module fills them.
That is why the architecture below describes `module-uo` as *the worked example* rather than
as part of the platform. It is the module every other module is measured against, not a
component core depends on.
## The invariants
These hold across repository boundaries, and every one of them is load-bearing.
### The game is never network-reachable
The ServUO shard **dials out** over loopback TCP `127.0.0.1:7788`, newline-delimited JSON,
to the sidecar. The sidecar is the listener; the game opens no port. Only the sidecar is
exposed, and only the website's backend talks to it.
See [The bridge](/docs/architecture/the-bridge/).
### A wedged sidecar can never stall the game
On the C# side, `Emit()` enqueues onto a **bounded, drop-oldest** queue and returns
immediately. It never touches the socket from the game's core thread. Every world read
happens on the core thread; a dedicated writer thread drains the queue.
Dropping game events is strictly better than pausing the game to deliver them.
### The website degrades rather than fails
The sidecar REST client never throws — every call returns `{ ok, data, status }`. The public
site still renders with the shard shown offline.
That guarantee covers **reading the configuration too**: resolving the admin-managed config
decrypts a stored token, which throws if the ciphertext cannot be authenticated (a rotated
`SECRET_ENC_KEY`, or a database dump restored under a different key). That is caught inside
the client and reported as unavailable, so a wrong key degrades the shard surface instead of
500-ing it — and the admin config screen keeps working, which is the screen you need in order
to recover.
### Sensitive events never reach the public
Ingested events fan out over two SSE channels: a **public allowlist** stream, and an
**admin-only** stream that additionally carries staff audit, cheat detection and login
attempts with IPs.
**The catalog is the module's; the boundary is core's.** A module declares which of its
kinds are public-safe, and core enforces the split. A sensitive kind cannot reach the public
channel.
### A failed module never takes the site down
The loader catches failures across a module's entire lifecycle and marks it
`startup_failed`. The site comes up with that module's routes and navigation absent, and the
admin panel says why. See [Module
lifecycle](/docs/modules/module-lifecycle/#failure-is-contained-by-construction).
### Secrets are encrypted at rest
OAuth client secrets, the sidecar token and the Gmail refresh token are AES-256-GCM
encrypted, keyed by `SECRET_ENC_KEY`. **The sidecar token is write-only in the API** — it is
never returned to any client.
<Aside type="caution" title="Rotating that key orphans every stored secret">
Nothing re-encrypts. What was stored under the old key can no longer be read, and every
stored secret has to be entered again. See [Environment
variables](/docs/reference/environment-variables/).
</Aside>
## A deploy is two independent installs
Worth stating plainly, because it is the single most common misunderstanding: **the
installer binary sets up the shard side only, and never contacts the website.** The website
is a separate Docker deployment on, usually, a different machine.
The [installation path](/docs/getting-started/requirements/) walks both in order.
## Canonical documents
[`ARCHITECTURE.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/ARCHITECTURE.md)
holds the canonical diagram, and
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
is the full API, schema and security contract. See [Canonical
documents](/docs/reference/canonical-documents/) for the whole map.

View File

@@ -0,0 +1,153 @@
---
title: Teams architecture
description: Teams is a contract, not a surface — how core owns guilds, clans and corporations without ever learning what one is called.
---
import { Aside } from '@astrojs/starlight/components';
Most games have groups: guilds, clans, corporations, tribes, crews. Runic Gateway supports
them as a **core platform primitive**, while core itself never learns what yours is called.
The administrator's view is [Teams](/docs/administration/teams/).
## The sentence the design turns on
**Teams is a contract, not a surface.**
Core owns the tables, the sync, the access rules and the activity feed. It does **not** own
the word for a Team, and therefore does not own the Team *page*. The module that owns the
vocabulary owns the page.
That was not the first design. Core originally rendered Team pages with slots a module
filled. It was inverted, and the inversion is the interesting part: instead of core naming
places for a module's content, **a module declares a place on its own page for core to
fill** — `registry.declareModuleSlot(id, name, { core })`, with core offering contributions
rather than naming slots.
<Aside type="caution" title="Why the direction matters">
The first version had core's fills naming three of `module-uo`'s slots **literally**. It
worked for exactly one module and silently did nothing for any other game — an empty page
with nothing logged.
It was found by writing the Integration Kit for an audience outside this project, which is
precisely what that book is for.
</Aside>
## Six invariants
Each has a test named against it.
1. **Module unavailability is staleness, never emptiness.** No Team subsystem may apply a
destructive result derived from a failed, timed-out or unanswered module call.
2. **Four authority paths stay four.** Game membership, leadership, forum access and
external-platform access are separate tables answering separate questions, resolved by
separate predicates. **No predicate reads another's table.**
3. **Non-contamination.** A manual forum grant never writes the membership projection, in
either direction, ever. Both facts coexist; neither migrates into the other.
4. **A Team's name is immutable for the life of its record.** A rename is an archive plus a
create.
5. **Core never interprets module vocabulary.** Activity kinds, Team metadata and capability
strings are opaque. Core stores, gates and displays; it never branches on content it does
not own.
6. **The game never touches the website.** Everything crosses the sidecar.
Invariant 1 deserves emphasis, because it is the one a naive implementation gets wrong: if
the module fails to answer "who is in this Team?", the answer is **not** "nobody". Treating
a timeout as an empty roster would silently disband every Team on the site.
## The rename rule
Core's key is **(`module_id`, `external_id`, `name`) taken together** — not `external_id`
alone.
| Situation | What core does |
|---|---|
| New `external_id` | Create a Team |
| Known id, same name | Update in place |
| Known id, **different name** | **Archive** the row and create a new one |
| Id absent from an authoritative full list | Archive as disbanded, subject to invariant 1 |
The archived Team keeps its forum, activity history, grants and integration record; all
become read-only. It stays reachable at its old slug, `noindex`, with a banner linking to
the successor — so a Discord message from before the rename lands somewhere that explains
itself instead of 404-ing.
This puts the whole of *"is this a rename or a different group?"* **inside the module**. If
your game has no persistent group id, synthesise `external_id` from whatever is stable, or
fold the name into it so every rename is a fresh id. Core only ever sees "an id appeared /
an id's name changed / an id is gone".
## The module-facing interface
A module registers a provider:
```js
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
```
and pushes through `ctx.teams`:
| Call | What it does |
|---|---|
| `ctx.teams.publish(event)` | An optimisation — makes a membership change visible at once |
| `ctx.teams.reconcile({ reason })` | A debounced *request*; returns immediately |
| `ctx.teams.activity.push(items)` | Writes the per-Team feed |
**`ctx.teams` is push-only, and that is the contract.** There is no reader. A module
*answers* questions about Teams; it does not ask them. A `getTeamRoster` would be core
offering to read back the module's own answer — which the module already holds.
All three are fire-and-forget and never reject, because they are called from inside
game-event handlers and a storage problem of core's must not become the module's control
flow. Correctness comes from reconciliation either way.
### The six event kinds
`team.created` · `team.disbanded` · `team.member.added` · `team.member.removed` ·
`team.leader.added` · `team.leader.removed`
Six rather than four because **leadership is its own authority path**: a leadership change
has to be expressible without pretending someone joined or left.
**`team.created` and `team.disbanded` only ask for a reconciliation.** Core will not invent
a Team from a delta — it would have no name, no roster and no leaders — and will not archive
one from a delta either, because an archive driven by a message that may simply have been
repeated is destruction on no evidence.
### The activity feed
Each item carries an already-**rendered** `summary`, which core stores verbatim. Core cannot
phrase "gained 15,000 gold" for a game whose vocabulary it does not know, and a core that
templated it would have re-acquired exactly the semantics the module system exists to
remove.
`visibility` defaults to `'members'` — **fail closed**. The module chooses it per item; core
enforces it on read.
A `dedupeKey` collision is a **successful no-op**, which is what makes a sidecar reconnect
backfill safe to replay.
## Untrusted game data becomes a public page
This is the sharpest edge in the whole subsystem: a group name chosen by a player becomes a
page on a public website.
So game-sourced names go through **reserved-name screening**, and game-sourced overrides
through an **approval gate**. Neither is optional, and neither is something a module can
waive.
## What is deliberately out of scope
Multi-module namespacing, Team hierarchies and alliances, cross-Team messaging, and
platform-only Teams with no game backing.
**Matrix is research, not a roadmap item.** Of the five capabilities a shared interface
would name, a Matrix implementation could honestly provide two — it has no
channel-with-overwrites, no role object, no voice channel, and no slash-command
registration. The settled outcome was a *capability contract*, not an integration.
## Canonical document
[`TEAMS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/TEAMS.md)
is normative — Part 1 for the invariants, Part 2 for the core, Parts 34 for pages and the
activity feed.

View File

@@ -0,0 +1,131 @@
---
title: The bridge
description: How a game server reaches the website without ever being reachable itself — the sidecar, the loopback socket, and the rules that keep the game running.
---
import { Aside } from '@astrojs/starlight/components';
The bridge exists to answer one question safely: **how does a private game server's live
state reach a public website?**
The answer is a **sidecar** — a small service that owns the connection to the game and the
durable copy of what the game said. It is not optional, and the reasons are worth
understanding before you build one for another game.
## The shape
```
ServUO shard ──dials out──▶ uo-link sidecar ──HTTP + WS──▶ website
(C# plugin) 127.0.0.1:7788 (Rust) bearer + version (module)
newline JSON
▲ │
└──────── the game opens NO port ──────┘
```
Three properties fall out of that diagram, and each is a rule rather than an
implementation detail.
## 1. The game dials out
**The sidecar is the listener. The game connects to it.** The shard opens no port at all,
and nothing on the internet can reach it even in principle.
This inverts the intuitive design — you would expect the thing with the data to serve it —
and the inversion is the whole security argument. Only the sidecar is exposed, and only the
website's backend talks to the sidecar.
The transport is deliberately boring: **newline-delimited JSON, one object per line**, over
loopback TCP.
## 2. A wedged sidecar must never stall the game
This is the constraint the plugin is built around.
On the C# side, `Emit()` **enqueues onto a bounded, drop-oldest queue and returns
immediately**. It never touches the socket from the game's core thread. Every world read
happens on the core thread; a dedicated writer thread drains the queue.
<Aside type="caution" title="Dropping events beats pausing the game">
If the queue fills, the oldest events are discarded. That is the correct trade: a game
server that stutters because a logging sidecar is slow is a broken game server, and no
website feature is worth a lag spike.
Design your own plugin the same way. The game thread must never block on I/O — not on a
socket, not on a lock held by a writer, not on a DNS lookup.
</Aside>
Inbound commands get the mirror rule: **every inbound handler marshals to the core thread
before touching world state.**
## 3. The sidecar persists before it forwards
The sidecar owns a durable store. It is not a proxy that translates and forgets — if the
website is down, the game's events are still recorded, and a reconnecting website catches
up.
This is what "a *thin* sidecar" means in the Integration Kit: thin in *logic*, not thin in
responsibility. The sidecar is a **dumb forwarder** — it makes no access-control decisions
and holds no policy. Access control and the admin-toggleable visibility scope live on the
**website**, where an administrator can see and change them.
## Two ways in
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
notable kinds append to an events log, and high-frequency kinds only update state rather
than accumulating history.
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
Every call carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header. **A
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
versions](/docs/architecture/protocol-versions/).
## What the shard can say
The catalog spans sessions and identity, character state, economy and commerce, housing and
IDOC, combat and PvP, progression, cheat detection and staff audit, and server lifecycle.
A representative line looks like:
```json
{"t":1752,"kind":"vendor.sale",
"buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
"owner":{"serial":"0x33C1","acct":"Feng"},
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},
"price":75000,"commission":3750}
```
The full catalog is [Event catalog](/docs/reference/event-catalog/).
## Two design details worth stealing
**`server.hello` is per-connection, not per-boot.** The sidecar restarts independently of
the game, so anything it needs up front must be re-sent on **every** connect. An earlier
draft emitted a "started" event once at boot; a sidecar that came up second never received
it and had no idea which shard it was attached to.
It carries a `bootId` — a GUID generated at server start, stable across sidecar reconnects
and changed on every game restart. That is how the sidecar tells *"I reconnected"* (keep
cached state) from *"the game restarted"* (discard it).
**Rosters are sets, not signatures.** Guild membership is compared as a set rather than
folded into a checksum, because a sum can collide: one member joining and another leaving
between two sweeps offset each other, and the guild reads as unchanged. A set can also be
*differenced*, which is what makes per-member leave events possible for a game that raises
no event for leaving.
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving — an
unknown roster becoming known is not 155 people leaving at once.
## Building one for another game
The bridge is not UO-specific in shape, only in vocabulary. Chapters 3 and 4 of [the
Integration Kit](/docs/modules/the-integration-kit/) cover the sidecar and the game-side
plugin, and they are the two parts where the mistakes are most expensive.
## Canonical documents
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
§5 and §7 are the data catalog and the wire protocol;
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
is the integration guide. Both are normative; this page is not.

View File

@@ -0,0 +1,161 @@
---
title: Building a module
description: The repository layout, the server half, and the client build — including the three things about bundling that everyone gets wrong once.
---
import { Aside } from '@astrojs/starlight/components';
Start from [the Integration Kit's template](/docs/modules/the-integration-kit/) rather than
an empty directory. This page explains what the template is doing and why, so that when you
change something you know what you are changing.
## The layout
One repository, both halves, versioned together:
```
module.json id, version, coreApi, mounts, extensions
server/index.js the entry point — exports register(ctx, api)
server/db/schema.sql idempotent fragment, replayed every boot
server/db/purge.sql destructive; only ever run by an explicit purge
server/router/ routers and controllers
server/model/ *.model.js (logic) + *.db.js (SQL) pairs
client/src/entry.jsx registers routes, nav, providers
client/src/shim/ the shared-dependency shims — see below
client/dist/entry.js PREBUILT chunk, published by your CI
```
`client/dist/` is committed by your **release**, not by hand — the operator never builds,
so the built chunk has to be in the bundle.
## The server half
`server/index.js` exports one function, called once during core's require phase:
```js
module.exports = function register(ctx, api) {
const log = ctx.log('examplegame')
api.registerRoutes({
public: { '/world': worldRouter(ctx) },
})
api.onBoot(async (ctx) => {
// anything that needs a live database goes HERE, not above
})
}
```
Follow core's own layering — `router → controller → model → db`, with `.model.js` (logic)
and `.db.js` (SQL) pairs, and raw parameterised queries. There is no ORM anywhere in this
project, and a module that introduces one is a module nobody else can read.
### The rule CI enforces
**Zero `require`/`import` may reach outside your own directory.** Not "few". Zero.
```bash
npm run check:imports --prefix server
```
If you need something from core that `ctx` does not offer, that is a gap in the contract —
raise it, so the surface grows deliberately. Reaching into core's internals is how a module
breaks on a refactor it had no part in.
## The client half
Your chunk is built with Vite in **library mode**, emitting one unhashed `dist/entry.js`.
Unhashed deliberately: `module.json` names that file, and a hashed name would have to be
discovered at runtime. Core answers the caching question instead, serving it `no-cache`.
Then three things about the bundling, each of which has already cost somebody a day.
### 1. Aliases replace `external` — they do not accompany it
This is the one that looks most like it should work.
Rollup asks `external` **before** Vite's alias resolver runs, so a specifier listed there is
marked external and **never aliased**. The chunk then ships bare `import 'react'`
specifiers, which a browser cannot resolve without an import map — and an import map has to
be inline, which `script-src 'self'` forbids.
The first real module shipped with both, **built cleanly**, and emitted exactly that chunk.
```js
rollupOptions: { external: [] }, // deliberately empty
```
Alias only. Nothing in `external`. (`output.globals` does not rescue this either — it covers
iife/umd and does nothing for an ES module.)
### 2. Use the array form of `resolve.alias`, with anchored regexes
Vite's **object** form does *prefix* matching, so a `react` key also rewrites
`react/jsx-runtime` — silently, to the wrong shim. The chunk then fails at its first element
with a message about `jsx` not being a function, which points nowhere near the cause.
```js
alias: SHARED.map(({ specifier, shim }) => ({
find: new RegExp(`^${escape(specifier)}$`),
replacement: shim,
}))
```
`^react$` and `^react/jsx-runtime$` cannot collide.
### 3. Assert at resolution time, not by grepping the output
The risk is a missed alias welding a **second React** into your chunk. That loads fine and
then throws about an invalid hook call somewhere unrelated.
The template fails the build if any shared package resolves into `node_modules`. Two details
of how it does that are not interchangeable:
- It hooks **`transform`, not `load`**. `load` is first-wins, so an earlier plugin returning
the module's contents means the guard is never called. Written against `load`, it sat in
the build doing nothing while a deliberately-broken alias produced a green build with
react-router welded in.
- The list of packages that may not be bundled is stated **independently** of the alias
list. Deriving one from the other means deleting an alias also deletes the guard against
what that alias prevented.
<Aside type="caution" title="Why shims rather than plain externals">
Each shared dependency is aliased to a two-line module re-exporting from `window.__rg`.
The **named** re-exports matter: `import { useState } from 'react'` compiles to a named
import, and a shim with only a default export fails at link time in the browser with a
message about the binding — not about the shim.
Route every shim through one file that reads `window.__rg` and throws a useful error when
it is missing. Otherwise the first symptom of a core ordering fault is
`Cannot read properties of undefined (reading 'react')` thrown from a file called
`react.js`, which reads like *your* bundling is wrong when it is the opposite.
</Aside>
Verify with:
```bash
npm run build --prefix client # build BEFORE the tests — two of them read the chunk
npm run check:externals --prefix client
```
## Registering the client half
```js
const { registry } = window.__rg
registry.registerRoutes(ID, {
public: [{ path: 'world', element: <WorldStatus /> }],
admin: [{ path: 'link', element: <Admin /> }],
})
registry.registerNav(ID, { … })
```
Paths are **relative to your module's segment** — `path: 'link'` under `admin` becomes
`/admin/<id>/link`. Check `window.__rg.version` against your `coreApi` range and refuse to
register on a mismatch.
## Then
[Testing and release](/docs/modules/testing-and-release/) covers CI, the checks, and
publishing the bundle and its manifest.

View File

@@ -0,0 +1,115 @@
---
title: Installing modules
description: How a module reaches a deployment — the install manifest, the two surfaces that can install one, and which of them wins.
---
import { Aside } from '@astrojs/starlight/components';
There is no catalog, and there is no marketplace. A module is installed by **naming the
URL of a release's install manifest**.
That is a design decision rather than an unfinished feature: a catalog would make core's
release cadence decide which modules exist, and the whole point of the module system is
that it does not.
<Aside type="note" title="Doing this once, as an operator?">
[Install a game module](/docs/getting-started/install-a-game-module/) walks the happy path,
and [Managing modules](/docs/administration/managing-modules/) covers the screen
afterwards. This page is about how distribution works, for people publishing one.
</Aside>
## What a release publishes
Two artifacts:
- **`<id>-<version>.tar.gz`** — the bundle: `module.json`, the server half, the prebuilt
client chunk, and the SQL fragments.
- **An install manifest** — small JSON carrying the bundle's URL and its **`sha256`**.
The manifest URL is the thing an operator pastes. The bundle is downloaded, **verified
against the `sha256`**, and unpacked into `modules/<id>/` on the mounted volume.
Nothing is compiled at any point in that sequence.
## The two surfaces
Both write the same `installed_modules` row, and neither needs a build step.
### The admin panel
Paste the manifest URL, press Install, then **restart** — a button on the same screen, not
an instruction to go and restart the container. It runs the lifecycle shutdown and exits,
and the supervisor declared in the shipped Compose file brings the process back.
That is why `restart: unless-stopped` is called out as load-bearing on [Install the
site](/docs/getting-started/install-the-site/). Without a supervisor, that button takes the
site down and leaves it down.
### The `MODULES` environment variable
For hosts managed by Compose rather than by clicking. Each entry is:
```
<id>@<version>=<install manifest URL>
```
Resolution runs **inside the server process**, before the volume is scanned — which is what
lets it write the same provenance columns a panel install writes. A module already unpacked
at the declared version is a no-op that makes **no network call at all**.
### By hand
`./modules` is a bind mount, deliberately rather than a named volume, so placing a module
directory there yourself is a **supported install**. A named volume would have routed that
through `docker cp`.
The image's own copy of `modules/` is excluded by `.dockerignore`, so a module sitting in a
builder's working tree can never ship inside an image.
## Which surface wins
They govern different things, and the split is worth memorising:
- **The declaration owns what is on the volume.**
- **The row owns whether a module runs.**
So uninstalling a declared module from the admin panel **returns its files at the next
start and leaves it disabled**. The files come back because `MODULES` still declares them;
it stays off because the row says so. That is the intended outcome, not a bug — but it
surprises people who expect the panel to be the last word.
## Upgrades
Paste the new release's manifest URL and install over the top. The bundle is verified,
unpacked over the old one, and takes effect at the restart.
An upgrade **deliberately leaves the state alone** — upgrading an enabled module must not
silently switch it off, and re-installing a disabled one must not silently switch it on.
<Aside type="caution" title="Check the API range first">
A module declares which core API versions it accepts. If a release needs a newer core than
your image provides, upgrade the site first — see [The module
manifest](/docs/modules/the-module-manifest/) for how that range is checked, and
[Maintenance and upgrades](/docs/administration/maintenance-and-upgrades/) for the site
half.
</Aside>
## Removal
Covered in full on [Managing modules](/docs/administration/managing-modules/); the shape
matters here because it constrains what you ship.
**Uninstall** is non-destructive: the row goes to `disabled`, the directory is removed, and
the module's **tables and data are retained**.
**Purge** is separate, explicit, and destructive — it runs your `purge.sql`. It is offered
in two places, and both are while the file is still on disk: as a standalone action on an
installed module, and as an opt-in checkbox in the uninstall dialog.
That second placement exists because of a real ordering trap: **`purge.sql` lives inside the
directory uninstall deletes**, so "purge afterwards" was never actually possible — it would
have left a disabled row whose Purge button had nothing to run.
The consequence, accepted and stated: an operator who uninstalls without ticking the box
keeps the tables, and getting rid of them later means reinstalling the module first. Write
`purge.sql` on the assumption it may be run long after anyone remembers what it drops.

View File

@@ -0,0 +1,103 @@
---
title: Module lifecycle
description: What core does to your module on boot, in what order, and what happens when any step of it throws.
---
import { Aside } from '@astrojs/starlight/components';
The five states are on [Managing modules](/docs/administration/managing-modules/), from the
operator's side. This is the same machine from inside the module — what core calls, when,
and what it does with a throw.
## The scan
The loader reads `modules/*/module.json` from the filesystem **synchronously, at require
time**. The database is not consulted: what is on the volume determines what mounts.
`MODULES_DIR` defaults to `<repo>/modules`, and Compose sets it to `/app/modules`. **A
missing modules directory is not an error** — "no modules installed" is the normal state of
bare core, and the loader must not make the mount mandatory to boot.
Modules load **alphabetically by `id`**, deterministically. There is no dependency
resolution between modules, and alphabetical order is the honest way of saying so: any
other order would imply a precedence nobody is computing. Do not build a module that needs
to load before or after another one.
## Validation, in order
Each step runs against your module. A failure at any step is **your module's failure and
nobody else's**.
1. `module.json` parses, has no unknown keys, and its `id` matches the directory name.
2. `coreApi` is satisfied by core's `MODULE_API_VERSION`.
3. Declared `mounts` prefixes are well-formed and collide with nothing.
4. Declared `extensions` slots all exist.
5. `schema` and `purge` files exist and are readable, and their table names are namespaced
or allowlisted.
6. `require()` of your server entry succeeds and exports a function.
7. `register(ctx, api)` returns without throwing, **and registers exactly what
`module.json` declared**.
Step 7 is worth reading twice. The manifest is not documentation of what you register — it
is a claim core holds you to. Registering something you did not declare fails, and so does
declaring something you do not register.
<Aside type="note" title="Collision detection probes the live routers">
Step 3 asks the actual tier routers whether a prefix is taken, rather than consulting a
list of core's prefixes. A hardcoded table was tried and was already one prefix stale by
the time it was written.
Mounting is also a **second pass** over the modules that survived validation, not part of
the scan loop — otherwise the first module's layers would already be on the router while
the second was validated, and the second would be told it collided with *core*, naming the
wrong culprit.
</Aside>
## Then the module runs
For each module that passed:
1. **Schema replay** — your `schema.sql` fragment is applied. It must be idempotent; it runs
on every boot.
2. **Routes and registrations** mount.
3. **`onBoot(ctx)`** is called, if you export one. This is where long-lived work belongs:
opening a stream, starting a poller, connecting to something.
On shutdown, **`onShutdown()`** is called. Disabling a module from the panel dispatches it
too, so the module actually stops — releases its sockets, closes its streams — rather than
merely becoming unreachable.
Enabling is deliberately **not** the mirror image: there is no `onBoot` re-dispatch, so the
panel offers a restart instead. If your `onBoot` is expensive or stateful, that asymmetry is
in your favour.
## Failure is contained, by construction
**A module that fails to load never takes the site down.**
The loader try/catches the module's **entire** lifecycle — require, validation, registration,
schema replay, `onBoot` — not merely failures that surface after a router object was
returned. Any failure at any point marks that module `startup_failed`, records the reason,
and the site comes up with that module's routes and navigation absent.
Two consequences to design around:
- **A failed module is retried on every restart.** There is no backoff and no quarantine.
A deterministically broken module re-records its failure each boot, which is the honest
thing for it to do.
- **`disabled` is the only state a boot leaves alone.** Every other non-disabled module is
reset to `enabled` at boot and then recorded as `started` or `startup_failed`. Disabling
is an operator's decision rather than an outcome, so it survives restarts untouched.
<Aside type="caution" title="Fail loudly and early">
Because failure is contained, a broken module is easy to *not notice* — the site comes up
fine and one section is missing. Validate your own configuration in `register()` or
`onBoot()` and throw with a message naming what is wrong. `startup_failed` with a good
reason is a far better outcome than a module that starts and then quietly does nothing.
</Aside>
## Where the loader is specified
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
Part 4 is the normative account of everything on this page, including the exact position of
the `load()` call in `app.js` and why it is load-bearing in both directions.

View File

@@ -0,0 +1,104 @@
---
title: Testing and release
description: The checks a module should run before it ships, what a release artifact actually is, and how the version that ships gets decided.
---
import { Aside } from '@astrojs/starlight/components';
## The checks
Four, and each exists because something got past review without it.
```bash
npm run check:imports --prefix server # zero imports leave the module directory
npm run build --prefix client # build FIRST — two tests read the chunk
npm run check:externals --prefix client # no shared dependency welded into the chunk
npm test --prefix server && npm test --prefix client
```
**`check:imports`** enforces [the zero-internal-imports
rule](/docs/modules/building-a-module/#the-rule-ci-enforces). It is the mechanical form of
the module boundary — without it, the boundary is a convention, and conventions lose.
**`check:externals`** is the one that catches a chunk shipping bare `import 'react'`
specifiers, or a second React welded in. Both build cleanly. Neither works in a browser.
<Aside type="caution" title="Build before you test">
Two client tests read the built chunk. Run them against a stale `dist/` and they will
happily pass on last week's output.
</Aside>
Also worth running your OpenAPI fragment check if you publish one — the filename is fixed
at `swagger-fragment.json` in the bundle root, so a module cannot point core at some other
file.
## What a release artifact is
**Not source.** An operator never builds anything, and that constraint shapes everything
here.
A release is **the directory core's loader expects to find at `modules/<id>/`, already
assembled** — the prebuilt client chunk, any runtime dependency installed, the schema
fragment, the OpenAPI fragment — packed exactly as it will be unpacked.
Two artifacts ship:
- `<id>-<version>.tar.gz`
- an **install manifest** carrying that tarball's URL and its `sha256`
The admin install downloads the tarball, verifies the hash, and unpacks it. **Nothing runs
`npm` on the way.**
## The version that ships is the tag
The template derives the next version from conventional-commit subjects since the newest
`v*` tag:
| Commits since the last tag | Result |
|---|---|
| `feat!:` or `BREAKING CHANGE` | major |
| `feat:` | minor |
| `fix:` / `perf:` | patch |
| Nothing releasable | **no release is cut** |
| First ever run, no tag | releases what `module.json` declares |
Your committed `module.json` version is a **floor and a starting point, not a record of the
last release**. Name a version there above the newest tag and that version is what releases
— which is still the natural way to say "this one is a minor" when a `coreApi` bump forces
the question.
<Aside type="note" title="Why derived rather than declared">
The obvious alternative is to let `module.json`'s version decide: you already have that
number, and two sources for one number is how they drift.
This project's reference module shipped that way and moved off it. The cost of a declared
version is paid on **every** release, and the drift it prevents is something review catches
anyway — a week of merged work there produced no bundle at all, because none of it happened
to touch that line.
</Aside>
## Pin the core you build against
Keep a `ci/core-ref.json` naming the exact core commit your module is written against, and
have CI assert your declared `coreApi` still holds against that core's
`MODULE_API_VERSION`.
Moving that sha is the moment someone re-reads what changed. It is the same mechanism [the
Integration Kit uses](/docs/modules/the-integration-kit/#the-pin-that-forces-a-re-read), and
the reason a contract bump upstream becomes a visible decision in your repository rather
than a silent one.
## Before you tag
A short list, all of it learned rather than invented:
- **The module boots on a real deployment**, not just in tests. [Failure is
contained](/docs/modules/module-lifecycle/#failure-is-contained-by-construction), so a
broken module is easy to not notice — the site comes up and one section is missing.
- **`schema.sql` is genuinely idempotent.** It runs on every boot, not once.
- **`purge.sql` still makes sense to someone who has forgotten your module**, because
[that is who will run it](/docs/modules/installing-modules/#removal).
- **Your `coreApi` range covers the oldest core you actually test against**, not just the
newest one you have.
- **Every capability string you publish is one you intend to keep.** Something outside your
repository is branching on them.

View File

@@ -0,0 +1,107 @@
---
title: The Integration Kit
description: The instruction book for putting a different game on the platform — four chapters, a buildable template, and an honest account of its status.
---
import { Aside } from '@astrojs/starlight/components';
The [Integration
Kit](https://gitea.whitlocktech.com/RunicGateway/Integration-kit) is a separate repository
whose entire job is teaching someone **outside this project** how to put a different game on
the platform.
<Aside type="caution" title="The kit describes itself as a draft, and so do we">
In its own words: *the kit is finished when someone outside this project builds a working
module for a new game by following it alone, without reading core's source. That has not
happened yet.*
We are not going to describe it as finished before that happens. If you are the person who
tries it, the places you get stuck are the most valuable thing the repository can receive —
[open an issue](https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues) saying
where you left the kit and what you did next.
</Aside>
## What it covers
Three things, because the reasons live in the joins between them:
```
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
(plugin: bounded queue, (owns the socket, (loads your module,
writer thread) persists, then forwards) serves the pages)
```
| Part | What it is |
|---|---|
| **The website module** | A bundle core loads at boot. The bulk of the work, and the only part every module needs |
| **The sidecar** | A small service owning the connection to your game server, and the durable copy of what the game said. **Not optional** |
| **The game-side plugin** | Whatever runs inside your game and feeds the sidecar, without ever letting the sidecar stall the game |
## The four chapters
| # | Chapter | What it covers |
|---|---|---|
| 1 | Your first module in twenty minutes | Copy the template, rename it, build it, install it, see a page. No theory |
| 2 | The website module | `module.json`, `register(ctx, api)`, the schema fragment, the client chunk, packaging, and what a module must never do |
| 3 | The sidecar | Why the website never talks to a game server, what "persist before you forward" means, and what a *thin* sidecar is |
| 4 | The game-side plugin | The least code and the highest stakes: never block the game thread |
Before any of them, the kit points at the [Rust dry
run](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md)
— a complete module designed on paper for a second game, and the shortest honest picture of
the whole job.
## The template is built, not just quoted
Chapters 1 and 2 quote `template/`, a real module that CI builds against a pinned core. The
code in those chapters is **a tree that is proved rather than prose that looks like one**.
Chapters 3 and 4 cite `uo-link` and `servuo-plugins` by file and identifier rather than by
line number, deliberately: those repositories move for their own reasons, and a line number
in a book is wrong the moment they do.
## The kit never re-specifies a contract
This is its governing rule, and it is the same one this site follows.
> Nothing in these chapters is normative. Where a chapter and one of these documents
> disagree, the document is right and the chapter has a bug.
| Authority | For |
|---|---|
| `MODULE_API.md` | Everything a module may do |
| `MODULE_SYSTEM.md` | Why the module system is shaped this way, and how a module is installed and removed |
| `link/PLAN.md` + `INTEGRATION.md` | The game ↔ sidecar wire protocol, as one real sidecar implements it |
The chapters teach the order to do things in, the reasoning, and **the mistakes that cost
this project time**.
## The pin that forces a re-read
`ci/core-ref.json` pins the exact core commit the kit is written against, and CI asserts
that the version `template/module.json` declares **equals** that core's
`MODULE_API_VERSION`.
Equality, not "satisfies". That is the mechanism, not a bug: a contract bump in the website
repository is *meant* to turn the kit red, so that someone re-reads the chapters before the
pin moves.
<Aside type="note" title="It has already earned its keep">
Writing the chapters against 1.6.0 found that core's inverted-slot fills named three of
`module-uo`'s slots **literally** — so the mechanism worked for that one module and silently
did nothing for any other game, producing an empty page with nothing logged.
That is exactly the class of defect a book written for an audience outside this org exists
to catch, and it was fixed in core before the pin moved.
</Aside>
## Running its checks
Dependency-free Node scripts, from the repository root — which is also how a reader runs
them:
```bash
node scripts/checkLinks.js # every relative link resolves; no commit permalinks
node scripts/checkRenameSites.js # the rename checklist matches the template tree
node scripts/checkChapterPaths.js # every path a chapter names in backticks still exists
```

View File

@@ -0,0 +1,171 @@
---
title: The module API
description: The two arguments core hands your module — what you can reach, what you can register, and the rules that govern both.
---
import { Aside } from '@astrojs/starlight/components';
Your server entry point exports one function:
```js
module.exports = function register(ctx, api) { /* … */ }
```
`ctx` is what core lends you. `api` is what you register with it. Everything crossing the
module boundary goes through one of the two.
The contract version is **`MODULE_API_VERSION`**, currently **1.6.0**, and your manifest's
[`coreApi` range](/docs/modules/the-module-manifest/#coreapi-and-what-a-range-means) is
checked against it before your code is required.
## The entry point runs early
`register()` is called **once, synchronously, during core's require phase — not after the
database is up.**
It must not `await`, must not touch the database, and must not throw for a reason a retry
would fix. Everything needing a live database belongs in `onBoot`.
<Aside type="caution" title="This constraint is not stylistic">
Core's route-manifest and OpenAPI generators both require the app with the connection pool
pointed at a dead port. A module that queried at registration time would hang both.
</Aside>
## `ctx` — what you can reach
Every member exists because a real module needed it. The surface is grown from demonstrated
need, never speculation.
| Member | What it gives you |
|---|---|
| `ctx.express`, `ctx.validator` | Core's own `express` and `express-validator` namespaces |
| `ctx.db.query`, `ctx.db.pool` | Parameterised SQL, and the pool for streaming work |
| `ctx.log(namespace)` | `error` / `warn` / `info` / `debug`, each `(msg, meta?)` |
| `ctx.settings` | `get`, `set`, `getInstanceName` |
| `ctx.auth.getUserFromRequest(req)` | `{ id, username, role }` or `null` |
| `ctx.push.publish` | Notification fan-out |
| `ctx.secretBox` | `encrypt` / `decrypt` for secrets at rest |
| `ctx.middleware` | `requireAuth`, `requireRole`, `siteMode`, `validate`, `noindex`, `rateLimit`, `accountChangeLimiter` |
| `ctx.uploads` | `upload`, `UPLOAD_DIR`, `MIME_EXT` |
| `ctx.posts` | `listAll`, `getById`, `linkAnnounceJob`, `markAnnounced` |
| `ctx.paths.moduleRoot` | Absolute path to your own directory |
| `ctx.activity.log` | The admin audit trail |
| `ctx.users.getById` | Read a user |
| `ctx.site.baseUrl` | Absolute base URL, no trailing slash |
| `ctx.moduleId` | Your id, from the manifest |
| `ctx.teams` | `publish`, `reconcile`, `activity.push` — see below |
`ctx` is frozen one level deep before you get it. That is a guard against accident, not
against a hostile module — the boundary is organisational, [not a security
boundary](/docs/modules/the-module-system/#the-boundary-is-not-a-sandbox).
### Three narrowings worth knowing
Core deliberately hands you **less** than the underlying utility exports.
- **`ctx.auth` is one function.** The full facade can mint sessions; minting is core's job.
A module that needs an identity needs to *read* one.
- **`ctx.settings` is three functions**, not the model's 24 — most of those are registration
and app-links policy that is core's business.
- **`ctx.posts` is four functions.** `create` / `update` / `remove` are the CMS, and the CMS
is not a module's.
<Aside type="note" title="Why `ctx.express` has to exist">
A module lives at `modules/<id>/`, outside `server/`, so Node's resolver never reaches
core's `node_modules` and a plain `require('express')` simply fails. Even where it
resolved, a second express in the process means a second `Router` prototype. Core owns one
express, exactly as it owns one React.
</Aside>
### `ctx.teams` is push-only, on purpose
There is no reader. A module **answers** questions about Teams; it does not ask them. Every
Team table is core-internal, and a `getTeamRoster` would be core offering to read back the
module's own answer — which the module already holds.
All three members are fire-and-forget and never reject, because they are called from inside
game-event handlers and a storage problem of core's must not become your control flow.
See [Teams architecture](/docs/architecture/teams-architecture/) for the whole shape.
## `api` — what you register
```js
api.registerRoutes({ public: {…}, admin: {…}, player: {…} })
api.registerExtension(slot, router)
api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })
api.registerSlashCommands([{ name, description, options, access, handler }])
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
Every call is synchronous, and **calling one twice is an error** rather than a
last-one-wins overwrite.
### Everything stages; nothing commits until you are known good
A claim's *shape* is checked at the call, so a malformed one throws with your own stack.
Whether a name is *taken* can only be answered once the whole batch is in, and is checked
when the loader commits.
The consequence is the one that matters: a module that registers two streams and then
throws **has left nothing behind**. A half-registered catalog would be worse than a missing
one — it is a subscribable stream that nothing will ever publish to.
### `registerRoutes` and the tier gate
One `express.Router()` per prefix per tier. The keys must match `module.json`'s `mounts`
exactly, and prefixes are one segment — no nesting, no parameters.
**The tier gate is already applied.** A router registered under `admin` sits behind
`noindex, isLoggedIn, requireRole('admin','editor','moderator')`; under `player`, behind
`noindex, requireAuth`; under `public`, behind nothing, by design.
Add per-route gates on top of that. **Never re-implement the tier gate** — a module that
rolls its own is a module whose access rules drift from core's.
Your router is mounted *inside* the tier, so it structurally cannot reach above its prefix.
## The client half
The client contract is its own thing. Core populates a global before it renders, and
freezes it afterwards:
```js
window.__rg = {
version, // MODULE_API_VERSION — the same number as the server's
react, // the React namespace
reactDom, // react-dom/client
router, // react-router-dom namespace
jsxRuntime, // react/jsx-runtime
registry, // routes, nav, feature providers, slots
ui, // the shared component kit
api, // the request primitive
}
```
Your chunk declares `react`, `react-dom` and `react-router-dom` as **externals** resolving
to that global — a global rather than an import map precisely because an import map must be
inline and `script-src 'self'` forbids inline script.
**`jsxRuntime` is not decoration.** Your bundler compiles every `.jsx` file to imports from
`react/jsx-runtime` under the modern automatic runtime, and those must resolve to *core's*
React like everything else. Without it on the global you would have to build with
`jsxRuntime: 'classic'`; with it, you use the default your tooling already assumes.
**`version` is there so your entry can check it.** A module entry compares
`window.__rg.version` against its own `coreApi` range and refuses to register on a
mismatch, logging once — the client-side twin of the boot-time check.
You register routes, navigation and feature providers through `registry`. See [Building a
module](/docs/modules/building-a-module/).
## The contract itself
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
is normative and complete — Part 2 for the server contract, Part 3 for the client, Part 4
for the loader's obligations and Part 5 for how they are enforced. This page is a map of
it, not a substitute.

View File

@@ -0,0 +1,114 @@
---
title: The module manifest
description: Every key in module.json, what the loader does with each, and why a typo is a boot failure rather than an inert setting.
---
import { Aside } from '@astrojs/starlight/components';
`module.json` sits at the root of your bundle. The loader reads it synchronously, before
anything else about your module runs.
**Unknown top-level keys are rejected, not ignored.** A misspelled key is a loud failure
rather than a silently-inert setting — which is the right trade when the alternative is a
module that boots and mysteriously does half its job.
## A complete manifest
```json
{
"id": "uo",
"name": "Ultima Online",
"version": "1.0.0",
"coreApi": "^1.0.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/shard", "/atlas"],
"admin": ["/shard", "/uo-link"],
"player": ["/shard"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["shard", "atlas", "market"]
}
```
## The keys
| Key | Required | Meaning |
|---|---|---|
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, and the client registry key — all at once. **Must equal the directory it was read from.** |
| `name` | yes | Human label for the admin Modules screen |
| `version` | yes | Semver. Recorded on install; shown on failure |
| `coreApi` | yes | Semver **range**, checked against core's `MODULE_API_VERSION` |
| `server` | no | Server entry point, relative to the module root. Absent means a client-only module |
| `client.entry` | no | The prebuilt ESM chunk, **in a subdirectory** — the directory it sits in is what gets served. Absent means a server-only module; present-but-empty is rejected, because it claims a client half and delivers none |
| `schema` | no | Idempotent SQL fragment, replayed every boot |
| `purge` | no | Destructive teardown. **Required if `schema` is present** |
| `mounts` | no | Declared route prefixes per tier |
| `extensions` | no | Core extension slots this module mounts into |
| `capabilities` | no | Opaque strings published to clients for feature detection |
## `mounts` is a claim, not a description
The loader compares your declaration against what your module **actually registers**, and
rejects a mismatch in either direction. Declaring a prefix you never mount fails; mounting
one you never declared fails too.
Prefixes are validated against `^/[a-z0-9][a-z0-9-]*$`, and the keys must match what you
register exactly.
<Aside type="caution" title="These are API prefixes, not page URLs">
`mounts` governs your **server** routes. Your SPA pages are registered separately by the
client half, and *those* are namespaced under your module id.
That is why `module-uo` declares `admin: ["/shard", "/uo-link"]` while its admin screen
lives at `/admin/uo/link`. Two different mechanisms, and [the module
system](/docs/modules/the-module-system/) explains why the split is deliberate.
</Aside>
## `capabilities` is for feature detection
Opaque strings, published by `GET /api/v1/public/modules` — and **only while the module is
`started`**. Clients like the SPA and the Android app read them to decide what to show.
They are not permissions and not mount prefixes. Keep them stable: something outside your
repository is branching on them.
## `coreApi` and what a range means
Core exports a single semver string, currently **1.6.0**. Your range is checked at boot,
before your code is required.
A **minor** bump adds members without removing any or changing a signature, so `^1.3.0`
keeps resolving against 1.6.0 — which is exactly why `module-uo` still declares `^1.3.0`
and runs fine.
Use a caret range against the oldest core you actually support and test against. Pinning
exactly buys nothing and strands you on the next additive release.
<Aside type="note" title="Not the same number as the protocol version">
`coreApi` versions the **website module contract**. `PROTOCOL_VERSION` versions the **shard
wire** and says nothing about a website module. See [Protocol
versions](/docs/architecture/protocol-versions/).
</Aside>
## Schema and purge
`schema` runs on **every boot**, so it must be idempotent — `CREATE TABLE IF NOT EXISTS`,
and additive migrations written so a replay is harmless. Table names must be namespaced or
allowlisted; the loader checks.
`purge` is required whenever `schema` is present, because a module that can create tables
must offer a way to remove them. It is only ever run by an explicit purge — never as part
of an uninstall.
Remember [where `purge.sql` lives](/docs/modules/installing-modules/#removal): inside the
directory an uninstall deletes. Write it to be run by someone who no longer remembers what
your module created.
## The full specification
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md)
§2.1 is normative for the manifest, and §2.6 for the schema fragments.

View File

@@ -0,0 +1,103 @@
---
title: The module system
description: What a module is, why the platform is built this way, and the one rule about URLs that catches everybody once.
---
import { Aside } from '@astrojs/starlight/components';
Runic Gateway's core knows nothing about any particular game. Everything that makes the
site a *Ultima Online* site — the shard status, the atlas, the market, the guild pages —
lives in a **module**, installed onto a running deployment.
This section is the builder's track. If you only want to install one, that is
[Install a game module](/docs/getting-started/install-a-game-module/) and
[Managing modules](/docs/administration/managing-modules/).
## What a module is
One repository producing one bundle, with a server half and a client half that version
together — so a route and the screen that calls it can never be mismatched.
A module owns:
- **Its routes**, server and client
- **Its schema**, as a fragment core replays on boot
- **Its navigation entries**, interleaved into core's groups rather than parked in a
section of their own
- **Its vocabulary** — the words a player of *that* game expects
Core owns the account, the session, the roles, the posts, the uploads, notifications and
Teams. A module reaches all of that through a defined surface, [the module
API](/docs/modules/the-module-api/).
## Why it is built this way
Three constraints had to hold at the same time, and between them they determined almost
everything else:
1. **Production is a prebuilt, pull-only image.** Operators do not build. There is no
compile step anywhere in installing a module.
2. **Modules live on a mounted volume**, not inside the image — a bind mount of
`./modules`. That is what lets a module be added to an image that knows nothing about
it.
3. **`script-src 'self'`.** The content-security policy forbids inline script, which rules
out an import map and is why core shares React on a global instead. See [Building a
module](/docs/modules/building-a-module/).
Install and uninstall need a **restart** — never a rebuild.
<Aside type="note" title="One active module per deployment">
Multi-module deployments are deliberately out of scope. `module_id` columns exist so the
idea stays later-friendly, but nothing exercises them, and no one should design around
them today.
</Aside>
## The boundary is not a sandbox
A module runs **in the same Node process, with full access**. Say that plainly, because
the word "module" invites the opposite assumption.
The boundary is a **code-organisation and distribution boundary, not a security
boundary**. For a self-hosted operator installing software they chose, that is the same
trust category as running its schema fragment — which they are also doing.
What the boundary buys is that modules talk to core through a *defined* surface, so a core
refactor cannot silently break a module. That rule is enforced mechanically rather than by
review: **a module must run with zero `require`/`import` reaching outside its own
directory**, and CI checks it. A gap in the surface extends the surface; it is never
worked around with a deeper import.
## The URL rule, and its one exception
**A module owns one path segment wherever it appears.** For a module with id `uo`:
| Surface | Path |
|---|---|
| Public pages | `/uo/shard`, `/uo/atlas`, `/uo/market` |
| Admin pages | `/admin/uo/link`, `/admin/uo/visibility` |
| Player pages | `/player/uo/…` |
**API routes are the exception, and keep their exact paths.** The shard admin API is still
`/api/v1/admin/shard/*`, not `/api/v1/admin/uo/shard/*`. This is why the Android app and
the Discord bot needed no API changes at the cutover.
<Aside type="caution" title="This distinction has already cost real time">
The installer printed `<site>/admin/shard` — the pre-module path — well after the screen
had moved to `/admin/uo/link`. It was not caught quickly because **the old path does not
404**: the SPA has no route for it, so it redirects to the dashboard and looks like it
worked.
If you are moving an existing surface into a module, the SPA paths change and the API paths
do not. Grep for both.
</Aside>
Old paths are **not** redirected. That was a deliberate call — a visible boundary in the URL
rather than a hidden one — taken while the platform had no public deployments to break.
## Where the design of record lives
This page summarises. The normative document is
[`MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md),
and the contract itself is
[`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md).
Where this site and those documents disagree, they are right and this is a bug.

View File

@@ -0,0 +1,73 @@
---
title: Bridge.cfg
description: Every key the in-game plugin reads — the connection, the sweep intervals, the feature switches and the caps that keep untrusted game data bounded.
---
import { Aside } from '@astrojs/starlight/components';
import { bridgeCfg } from '../../../../data/reference.mjs';
`Config/Bridge.cfg` in the ServUO tree configures the plugin — what it connects to, how
often it sweeps the world, and which features it publishes.
[The installer](/docs/reference/installer-cli/) puts it there. Editing it is a shard
operator's job, not a builder's.
## How to read this file
Three kinds of key, and they carry very different risk:
- **Connection** — where the sidecar is, and how much the plugin may buffer.
- **Sweep intervals** — how often the plugin walks part of the world. **These are the
performance dial.** Every sweep runs on the game's core thread, so shortening one costs
the game, not the sidecar.
- **Caps and switches** — feature toggles, and the bounds on anything a player can
influence.
<Aside type="caution" title="The caps are a security control, not tuning">
`TownCrierMaxLineLength`, `NewsMaxBodyLength`, `AccountNameMaxLength` and their siblings
bound data that crosses between a public website and a game world in both directions.
`AdminWriteEnabled` is **off by default**, and it is the switch that decides whether the
website may write to the game at all. Turn it on deliberately, having read
[`ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md).
</Aside>
## Every key
{Object.entries(bridgeCfg).map(([group, keys]) => (
<div key={group}>
<h3>{group}</h3>
<table>
<thead><tr><th>Key</th><th>What it is for</th></tr></thead>
<tbody>
{Object.entries(keys).map(([name, why]) => (
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
))}
</tbody>
</table>
</div>
))}
This list is checked against the shipped `Bridge.cfg` on every build, so a key added by a
protocol change turns this page red rather than going undocumented.
## Two that deserve their own note
**`QueueCap`** bounds the drop-oldest queue between the game and the writer thread. When it
fills, the **oldest events are discarded** — which is the correct behaviour, because the
alternative is a game server that stutters when a sidecar is slow. Raising it buys tolerance
for longer sidecar outages at the cost of memory; it never buys correctness.
**`GuildRosterMembersPerLine`** exists because a roster is the only fat frame this bridge
emits — a real 155-member guild measured about 10.8 KB. Rosters are **split** across lines
rather than sent oversized. See [The bridge](/docs/architecture/the-bridge/).
## Canonical documents
The shipped
[`Bridge.cfg`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/src/branch/main/overlay/Config/Bridge.cfg)
is the authority;
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
§10 documents the config keys and
[`SHARD_PREREQS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md)
covers what a shard needs before any of this works.

View File

@@ -0,0 +1,70 @@
---
title: Canonical documents
description: Where the normative specifications live — the documents that win whenever this site disagrees with them.
---
import { Aside } from '@astrojs/starlight/components';
import { canonicalDocs } from '../../../../data/reference.mjs';
Everything on this site is a **summary**. These are the documents it summarises, and where
the two disagree, **they are right and this site has a bug**.
<Aside type="note" title="Why say that so bluntly">
A documentation site that quietly re-specifies a contract becomes a second source of truth,
and second sources of truth drift. Every page here links out for exactly this reason, and
this page is the index of what it links to.
If you find a disagreement, it is worth reporting — it means a check is missing.
</Aside>
## The documents
<table>
<thead><tr><th>Document</th><th>Answers</th></tr></thead>
<tbody>
{Object.entries(canonicalDocs).map(([docPath, why]) => (
<tr key={docPath}>
<td>
<a href={`https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/${docPath}`}>
<code>{docPath}</code>
</a>
</td>
<td>{why}</td>
</tr>
))}
</tbody>
</table>
Every path above is checked to still exist on every build, so a document that is renamed or
moved turns this page red rather than leaving a dead link.
## Which document answers which question
- **"May a module do this?"** → `MODULE_API.md`. It is the contract, and it is the only thing
that can answer yes.
- **"Why is the module system like this?"** → `MODULE_SYSTEM.md`.
- **"What does this API return?"** → your own deployment's `/api/docs`, then
`BACKEND_DESIGN.md` §4.
- **"What can the shard send?"** → `link/PLAN.md` §5, and `v4.md` for the current protocol.
- **"Who may see this?"** → `SHARD_VISIBILITY.md` for the administrator's view,
`modules/uo/API.md` §4 for the specification.
- **"How do I set a shard up?"** → `installer/INSTALL.md`.
## Where they live
All of them are in
[`RunicGateway/docs`](https://gitea.whitlocktech.com/RunicGateway/docs), which is Markdown
only and versioned independently of the code it describes.
**A code change is not complete until `docs` reflects it.** That is a rule in the
project's own contributor guidance, not an aspiration — a change to behaviour, protocol,
endpoints, schema, configuration or the deployment model requires a matching edit there.
## Two things that are not in `docs`
**The Integration Kit** is its own repository, because its audience is outside this project
and it teaches rather than specifies. See [The Integration
Kit](/docs/modules/the-integration-kit/).
**The OpenAPI specification** is generated and committed in `website` itself, because it is
derived from the routes rather than written alongside them.

View File

@@ -0,0 +1,66 @@
---
title: Environment variables
description: Every variable the site reads, what each is for, and the four it refuses to start without.
---
import { Aside } from '@astrojs/starlight/components';
import { envVars } from '../../../../data/reference.mjs';
Every variable in `website`'s root `.env.example` — **the file a Compose deployment actually
reads**, which is not the same file local development copies.
This list is checked against that file on every build, in both directions: a variable that
disappears upstream fails, and a variable added upstream that is missing here fails too.
<Aside type="caution" title="Four are refused at boot in production">
`SECRET_ENC_KEY` and `BOT_INTERNAL_KEY` are required in production and the server **will not
start** without them — `BOT_INTERNAL_KEY` even on a deployment running no Discord bot.
`JWT_SECRET` and the `DB_*` group are required everywhere.
The first boot is also when your admin account is written, so `ADMIN_USERNAME` and
`ADMIN_PASSWORD` are set-once-before-first-boot values, not fill-in-later ones.
</Aside>
## Every variable
<table>
<thead><tr><th>Variable</th><th>What it is for</th></tr></thead>
<tbody>
{Object.entries(envVars).map(([name, why]) => (
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
))}
</tbody>
</table>
## The three worth reading twice
**`SECRET_ENC_KEY`** encrypts secrets at rest — OAuth client secrets, the Discord bot token,
the shard's auth token. Changing it does **not** re-encrypt anything: what was stored under
the old key can no longer be read, and every stored secret has to be entered again.
**`COOKIE_SECURE=auto`** decides `Secure` per request, which is what lets one deployment
work both over HTTPS through a proxy and over plain HTTP on a LAN address. Forcing it either
way breaks one of those.
**`TRUST_PROXY`** is required behind a reverse proxy for secure cookies, real client IPs and
rate limiting to work at all. Without it, every request appears to come from the proxy — so
rate limiting and IP bans apply to your whole user base at once.
## Where to set them
A first install is [Install the site](/docs/getting-started/install-the-site/), which prints
a complete `.env` alongside its Compose file. Afterwards,
[Configuration](/docs/administration/configuration/) covers what is env-configured and what
is not.
**Most settings are not here.** Branding, navigation, theming and the shard connection are
**admin-managed and live in the database**, deliberately — so changing them does not mean
redeploying a container.
## Canonical source
`website`'s
[`.env.example`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/.env.example)
is the authority, and
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
§8 covers deployment.

View File

@@ -0,0 +1,104 @@
---
title: Event catalog
description: What a game server can tell the website, how those events are grouped, and the five-rung ladder that decides who may see each one.
---
import { Aside } from '@astrojs/starlight/components';
import { visibilityLadder } from '../../../../data/reference.mjs';
The events a shard emits, and the mechanism that decides who may see them.
The exact wire shapes are in the protocol specification and are **not** restated here — a
copy of a wire format is a copy that will be wrong after the next bump. This page is the map
and the security model.
## What the shard can say
Nine groups, from the data catalog:
| Group | Covers |
|---|---|
| Session & identity | Logins, logouts, account linking |
| Character state | Vitals, stats, skills, position |
| Economy & commerce | Gold movement, vendor sales, supply totals |
| Housing / IDOC | Decay stages, ownership, coordinates |
| Combat, death, PvP | Kills, deaths, notable fights |
| Progression & activity | Skill gains, points, leaderboards |
| Cheat detection & staff audit | Fastwalk and friends; staff property edits |
| Lifecycle | `server.hello`, shutdown, crash |
| Known gaps | Things ServUO offers no clean hook for |
A representative line:
```json
{"t":1752,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
```
Note that one. **Cheat and audit events exist, and they are exactly what must never reach a
public page.**
## How events are handled
Not all alike, and the difference is deliberate:
- **State-changing kinds** update tables. The current state is what a page renders.
- **Notable kinds** additionally append to an events log, because a history is worth
keeping.
- **High-frequency kinds** only update state. Accumulating history for something that fires
constantly buys nothing and costs a table that grows forever.
## The visibility ladder
Five rungs, in order, least privileged first:
<ol>
{visibilityLadder.map((rung) => (<li key={rung}><code>{rung}</code></li>))}
</ol>
Every feature declares the rung it is visible from, and individual **fields** can require a
higher rung than the feature that carries them — a character's presence may be public while
its *location* is staff-only.
This list and its **order** are checked against the module that enforces it on every build.
Order matters as much as membership: reasoning about "staff and above" depends on the rungs
being in the right sequence.
<Aside type="caution" title="This is a security boundary, not a filter">
It is applied in **three** places — at routes, at SSE subscribe time, and at the navigation.
All three, because a surface filtered in only two of them leaks through the third.
Defaults **fail closed**: an unresolvable viewer is anonymous, not privileged, and a feature
with no configuration is not public by accident.
</Aside>
## Two SSE channels
Ingested events fan out to browsers over two streams:
- a **public** stream, carrying only allowlisted kinds;
- an **admin** stream, which additionally carries staff audit, cheat detection and login
attempts with IP addresses.
**The catalog is the module's; the boundary is core's.** A module declares which of its kinds
are public-safe, and core enforces the split — a sensitive kind cannot reach the public
channel.
A viewer's rung is resolved **once, when the stream opens, and frozen for its life**. A
long-lived connection must not silently gain privilege because the session changed
underneath it. Configuration changes, by contrast, *do* take effect live.
## Administering it
[The shard connection](/docs/administration/the-shard-connection/) covers the admin screens,
and the visibility ladder is administrator-configurable per feature and per field.
## Canonical documents
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
§5 is the data catalog and §7 the wire protocol;
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
is the current protocol;
[`SHARD_VISIBILITY.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/SHARD_VISIBILITY.md)
is the administrator's guide to the ladder, and
[`modules/uo/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/uo/API.md)
§4 specifies it.

View File

@@ -0,0 +1,85 @@
---
title: HTTP API
description: How the site's API is organised, where the live specification is, and the gate each tier sits behind.
---
import { Aside } from '@astrojs/starlight/components';
The site's backend API is **OpenAPI 3.0**, and the specification is generated from the routes
themselves rather than maintained beside them.
<Aside type="note" title="Your own deployment serves the authoritative copy">
Every route, parameter and response shape is at **`/api/docs`** on your site, generated from
the code that is actually running — including any module you have installed.
That is the copy to trust. This page is a map of how it is organised; it does not restate
the routes, and a reference section that tried to would be wrong within a week.
</Aside>
## The tiers
Every route lives under `/api/v1/<tier>/`, and **the tier decides the gate**.
| Tier | Routes | Sits behind |
|---|---|---|
| `admin` | ~93 | `noindex`, `isLoggedIn`, `requireRole('admin','editor','moderator')` |
| `auth` | ~38 | Public by necessity; heavily rate-limited and bot-scored |
| `player` | ~24 | `noindex`, `requireAuth` — role-agnostic self-service |
| `public` | ~19 | Nothing, by design |
| `settings` | 2 | `requireAuth` + `noindex`, no role gate |
Plus two outside the versioned surface: **`/api/health`** and **`/api/csp-report`**.
Those two are deliberately not under `/api/v1`. A browser learns the CSP report path from the
policy header rather than from a client build, so it is not part of the versioned client
contract.
## Two things the tier table implies
**`player` is role-agnostic.** It is self-service for whoever is signed in, gated on
`requireAuth` alone and never on "is not staff". Staff are a *superset* of players — an
administrator has characters too, and a `player` route that excluded them would 403 an admin
off their own account.
**A module's routes inherit their tier's gate** and add their own on top. A module never
re-implements the tier gate; see [The module
API](/docs/modules/the-module-api/#registerroutes-and-the-tier-gate).
## Authentication
Three ways in, [one session model](/docs/architecture/authentication-architecture/):
- **Cookie** — `httpOnly` JWT, for the browser.
- **Bearer** — short access tokens plus rotated, hashed, revocable refresh tokens, for the
native app.
- **SSO** — OAuth2/OIDC with PKCE, and **link-only**: an external identity must already be
attached to an existing account.
Admin roles are **re-validated against the database on every request**, so a demoted user
loses access immediately rather than at token expiry.
## The sidecar's API is a different thing
The uo-link sidecar exposes its own small REST and WebSocket surface, reached **only** by the
website's backend. It carries `X-UOLink-Version` and answers `409` on a protocol mismatch.
It is not part of this API and is not served from your site. See [The
bridge](/docs/architecture/the-bridge/).
## Keeping the spec current
For contributors: the specification is generated from `#swagger.*` annotations next to each
route, and the output is committed.
```bash
cd website/server && npm run swagger
```
A route that is not in the specification is not finished. Modules publish their own
fragment, at a fixed filename in the bundle root, so a module's routes appear in the same
documentation as core's.
## Canonical document
[`BACKEND_DESIGN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md)
§4 is the API contract, including §4.0's authoritative route list.

View File

@@ -0,0 +1,85 @@
---
title: Installer CLI
description: The four commands the installer offers, what each does to a host, and the environment variable that makes a full run safe to rehearse.
---
import { Aside } from '@astrojs/starlight/components';
import { installerCommands } from '../../../../data/reference.mjs';
The installer is one binary per operating system that deploys the **shard side only**. It
never contacts the website.
Downloads and the walkthrough are [Connect a game
server](/docs/getting-started/connect-a-game-server/). This page is the command surface.
## The commands
<table>
<thead><tr><th>Command</th><th>What it does</th></tr></thead>
<tbody>
{Object.entries(installerCommands).map(([name, why]) => (
<tr key={name}><td><code>{name.toLowerCase()}</code></td><td>{why}</td></tr>
))}
</tbody>
</table>
`doctor`, `update` and `uninstall` are the day-two commands.
## Rehearsing a run
Two mechanisms, and they answer different questions.
```bash
runicgateway-installer install --servuo /path/to/ServUO --verify
```
**`--verify` writes nothing.** It reports what would change — the diff against the ServUO
tree — which is the right thing to run first against a shard that has players on it.
```bash
RUNICGATEWAY_STATE_DIR=/tmp/rehearsal runicgateway-installer install --servuo …
```
**`RUNICGATEWAY_STATE_DIR` relocates everything the installer writes** — state, data, and
the sidecar binary — *and suppresses service registration*. That is how a full run is
exercised without root, and it is what the project's own tests use.
## What an install actually does
1. Resolves a **bundle** — an exact, protocol-checked sidecar and overlay pair published by
CI. Never "latest of each"; see [Protocol
versions](/docs/architecture/protocol-versions/).
2. Syncs the plugin overlay into the ServUO tree, backing up whatever it is about to
overwrite.
3. Offers the opt-in patch tier.
4. Installs the sidecar and registers its service.
5. Prints four values to paste into the site's shard screen.
<Aside type="caution" title="Installer v0.1.0 prints an older path in step 5">
It names `<site>/admin/shard`. The screen moved to **`/admin/uo/link`** when the shard
surface became part of the `uo` module. Fixed in v0.1.1.
</Aside>
## Platforms
Linux `x86_64`, Linux `aarch64`, and Windows `x86_64`.
**macOS and Windows-on-ARM are deliberately absent**: the game server and the sidecar must
share a host, and no ServUO host is either.
Releases are **unsigned**, and `SHA256SUMS` is the trust anchor — verify before running.
Windows will show a SmartScreen prompt, which is expected for an unsigned binary.
<Aside type="note" title="Why the library target is called `rgdeploy`">
Windows UAC refuses to launch an unsigned executable whose name contains `install`
(`os error 740`), and Cargo names test harnesses after their target. It is deliberate, and
not something to tidy up.
</Aside>
## Canonical documents
[`INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
is the operator guide — including Appendix A, hand deployment, for hosts that cannot run the
binary — and
[`PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
is the design of record.

View File

@@ -0,0 +1,68 @@
---
title: sidecar.toml
description: The sidecar's entire configuration — four keys — and why the file is generated rather than shipped.
---
import { Aside } from '@astrojs/starlight/components';
import { sidecarConfig } from '../../../../data/reference.mjs';
The uo-link sidecar's configuration. It is deliberately tiny: the sidecar is a **dumb
forwarder**, and policy lives on the website where an administrator can see it.
## The file is written, not shipped
The sidecar **writes `sidecar.toml` on first run**, including a generated auth token. There
is no committed sample that is authoritative, and nothing is compiled into the binary.
Point it elsewhere with `$UOLINK_CONFIG`.
<Aside type="caution" title="Authentication is always on">
A blank token is not "no authentication" — it is auto-generated and written back, so the web
surface is authenticated from first boot. There is no way to turn it off, which is the
correct default for the one component that is exposed.
</Aside>
## The keys
<table>
<thead><tr><th>Key</th><th>What it is for</th></tr></thead>
<tbody>
{Object.entries(sidecarConfig).map(([name, why]) => (
<tr key={name}><td><code>{name}</code></td><td>{why}</td></tr>
))}
</tbody>
</table>
This list is checked against the sidecar's own config structs on every build, so a key added
upstream turns this page red rather than quietly going undocumented.
## What is *not* in here
Worth stating, because the absences are the design:
- **No allowlist, no audience rules, no visibility settings.** Those are the website's, and
admin-toggleable. The sidecar forwards; the site decides who may see what.
- **No website URL.** The website reaches the sidecar, not the other way round.
- **No protocol version.** It is compiled in, because a sidecar that could be *configured*
to claim a different protocol would defeat the check. See [Protocol
versions](/docs/architecture/protocol-versions/).
## Running it
```bash
cargo run # writes sidecar.toml on first run
RUST_LOG=debug cargo run # verbose, including heartbeats
```
Normally you do not run it by hand — [the installer](/docs/reference/installer-cli/)
installs it and registers its service.
## Canonical documents
The structs in
[`sidecar/src/config.rs`](https://gitea.whitlocktech.com/RunicGateway/link/src/branch/main/sidecar/src/config.rs)
are the authority;
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
is the design of record and
[`ADMIN_CONTROLS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md)
covers what the site may command the game to do.

190
src/data/reference.mjs Normal file
View File

@@ -0,0 +1,190 @@
/**
* The Reference section's enumerations.
*
* §1 says a Reference page is "a navigable summary plus a link to the canonical document —
* never a re-specification". This file is the line between those two things, and it is
* worth being explicit about where it falls:
*
* * The NAMES are here — every environment variable, every config key, every command,
* every event kind. A reference section that cannot answer "what variables are there?"
* without a click-through is a link farm.
* * The SEMANTICS are not. One terse line each, saying what a thing is FOR. Shapes,
* defaults that matter, interactions, and every "why" stay in the canonical document.
*
* Everything below is checked against its source by `scripts/checkReference.mjs`, in both
* directions — a name that disappears upstream fails, and a name that appears upstream and
* is missing here fails too. That is the whole reason it is safe to write names down at
* all: the enumeration cannot rot into fiction without turning the build red.
*
* Descriptions are NOT checked, and cannot be. They are the part a human has to keep
* honest, which is why they are kept short enough to re-read.
*/
/** `website` root `.env.example` — the file a Compose deployment actually reads. */
export const envVars = {
IMAGE_TAG: 'Which published image tag to run',
NODE_ENV: 'production or development — several refusals are production-only',
PORT: 'The port the app listens on',
INTERNAL_PORT: 'The internal-only listener, for the bot channel',
UPLOAD_DIR: 'Where uploads are written',
LOG_LEVEL: 'Console log level',
FILE_LOG_LEVEL: 'File log level, set separately',
LOG_TO_FILE: 'Whether to write a log file at all',
LOG_DIR: 'Directory for the log file',
LOG_FILE: 'Log file name',
BRAND_NAME: 'Site name — branding is data, not a build',
BRAND_SHORT_NAME: 'Short form, for tight spaces',
BRAND_TAGLINE: 'One line under the name',
BRAND_DESCRIPTION: 'Meta description',
BRAND_CONTACT_EMAIL: 'Published contact address',
BRAND_URL: 'Canonical public URL',
BRAND_ACCENT_COLOR: 'Accent colour',
BRAND_LOGO: 'Logo path',
BRAND_HERO: 'Hero image path',
BRAND_FAVICON: 'Favicon path',
DB_HOST: 'Database host',
DB_PORT: 'Database port',
DB_NAME: 'Database name',
DB_USER: 'Database user',
DB_PASSWORD: 'Database password',
DB_ROOT_PASSWORD: "The database container's root password",
JWT_SECRET: 'Signs session tokens. Rotating it logs everyone out',
SECRET_ENC_KEY:
'Encrypts secrets at rest. Required in production, and rotating it ORPHANS every stored secret',
JWT_EXPIRES_IN: 'Session lifetime',
COOKIE_SECURE: 'auto decides Secure per request, so HTTPS and LAN HTTP both work',
COOKIE_NAME: 'Session cookie name. Changing it invalidates existing sessions',
TRUST_PROXY: 'Needed behind a reverse proxy for secure cookies, real IPs and rate limiting',
DEBUG_TRUST_PROXY: 'Diagnostic for the above',
TOTP_CHALLENGE_TTL: 'How long a pending 2FA challenge is valid',
ADMIN_USERNAME: 'First admin, created only when no users exist',
ADMIN_PASSWORD: 'First admin password. Set it before the first boot, not after',
CLIENT_ORIGIN: 'Dev only — the Vite origin allowed through CORS',
BOT_INTERNAL_URL: 'Where the Discord bot listens',
BOT_INTERNAL_KEY:
'Authenticates the site↔bot channel. Required in production EVEN IF you run no bot',
NTFY_BASE_URL: 'Push notification relay base URL',
};
/** `link/sidecar/src/config.rs` → the TOML the sidecar writes on first run. */
export const sidecarConfig = {
'shard.bind': 'Loopback address the game plugin dials out to',
'web.bind': 'Address the website reaches the sidecar on',
'web.auth_token': 'Shared secret the website must present. Generated on first run if blank',
'store.path': "The sidecar's own durable store",
};
/** `installer` — `src/cli.rs`'s `Command`. */
export const installerCommands = {
Install: 'Set up the shard side: sync the overlay, install the sidecar, register its service',
Doctor: 'Diagnose an existing install',
Update: 'Move to a newer bundle',
Uninstall: 'Remove what install put there',
};
/** `servuo-plugins/overlay/Config/Bridge.cfg` — the plugin's config, grouped for reading. */
export const bridgeCfg = {
Connection: {
Host: 'Sidecar address the shard dials out to',
Port: 'Sidecar port',
QueueCap: 'Bounded queue depth. Full means drop-oldest — never block the game',
PublicConnectAddress: 'Address players connect to, published to the site',
LinkUrl: 'Where in-game account linking sends a player',
},
Sweeps: {
StatSweepSeconds: 'Character stat sweep interval',
DecaySweepSeconds: 'House decay sweep',
EconomySweepSeconds: 'Economy totals sweep',
ChampSweepSeconds: 'Champion spawn sweep',
PageSweepSeconds: 'Staff page sweep',
GuildSweepSeconds: 'Guild roster sweep',
CitySweepSeconds: 'City / governor sweep',
PresenceSweepSeconds: 'Who is online',
HousingSweepSeconds: 'Housing sweep',
},
Guilds: {
GuildRosterMembersPerLine: 'Frame cap — a roster is split rather than sent oversized',
GuildRosterGuildsPerTick: 'How many guilds are swept per tick',
},
Points: {
PointsSweepSeconds: 'Points sweep interval',
PointsLeaderboardEnabled: 'Publish a leaderboard at all',
PointsTopN: 'Leaderboard length',
PointsSystems: 'Which point systems to include',
PointsProfileEnabled: 'Show points on a character profile',
PointsProfileRank: 'Show rank as well as total',
},
Market: {
MarketEnabled: 'Publish player vendor listings',
MarketSweepSeconds: 'Market sweep interval',
MarketSweepBatch: 'Vendors per sweep',
MarketMaxListings: 'Cap on listings published',
},
Ruleset: {
RulesetEnabled: 'Publish the shard ruleset',
RulesetIncludeSchedule: 'Include the event schedule with it',
},
'Town crier': {
TownCrierMaxLines: 'Lines per notice',
TownCrierMaxLineLength: 'Characters per line',
TownCrierMaxActive: 'Concurrent notices',
TownCrierMaxDurationSec: 'Longest a notice may run',
},
News: {
NewsMaxTitleLength: 'Title cap',
NewsMaxBodyLength: 'Body cap',
NewsMaxExternal: 'How many site posts are carried in-game',
NewsAnnounceDurationSec: 'How long an announcement shows',
},
'Admin commands': {
AdminWriteEnabled: 'Whether the site may write to the game at all. Off by default',
AdminAccessFloor: 'Minimum in-game access level for admin actions',
AdminBroadcastMaxLength: 'Broadcast cap',
AdminReasonMaxLength: 'Reason field cap',
AdminBanMaxDurationSec: 'Longest ban the site may set',
},
Accounts: {
SignupMode: 'How game accounts may be created',
AccountCreateEnabled: 'Allow creation at all',
RequireIpForCreate: 'Require a real client IP',
AccountNameMaxLength: 'Account name cap',
AccountPasswordMaxLength: 'Account password cap',
},
};
/**
* The five-rung visibility ladder, from `module-uo`'s `server/utils/shardVisibility.js`.
*
* This one is a SECURITY boundary, not a convenience filter, which is why it is enumerated
* rather than described: a reader needs to see the whole ladder at once to reason about it.
*/
export const visibilityLadder = ['anonymous', 'logged_in', 'player', 'staff', 'admin'];
/** Canonical documents, by the question each answers. Checked to still exist in `docs`. */
export const canonicalDocs = {
'website/ARCHITECTURE.md': 'How the website fits together — the canonical diagram',
'website/BACKEND_DESIGN.md': 'The API, schema and security contract',
'website/MODULE_SYSTEM.md': 'Why the module system is shaped this way',
'website/MODULE_API.md': 'Everything a module may do — the contract',
'website/TEAMS.md': 'Teams as a platform primitive',
'website/SHARD_VISIBILITY.md': 'The audience ladder, for administrators',
'website/THEMING_AND_NAV.md': 'Admin-configurable theme, assets and navigation',
'website/TRUSTED_DEVICES_MFA.md': 'Trusted devices and the second factor',
'link/PLAN.md': 'The sidecar design of record, the data catalog and the wire protocol',
'link/INTEGRATION.md': 'Integrating with the sidecar',
'link/v4.md': 'Protocol 4, and its cross-repository obligations',
'link/ADMIN_CONTROLS.md': 'What the site may command the game to do',
'installer/INSTALL.md': 'The operator guide for setting a shard up',
'installer/PLAN.md': "The installer's design of record",
'modules/rust-dryrun.md': 'A second module designed on paper, to test that the contract generalises',
'modules/uo/API.md': "module-uo's own API, including its audience rules",
'android/PLAN.md': 'The Android app',
};