docs(builder): phase 8 — modules, architecture and reference
All checks were successful
PR checks / checks (pull_request) Successful in 1m13s
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:
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
100
src/content/docs/docs/architecture/protocol-versions.mdx
Normal file
100
src/content/docs/docs/architecture/protocol-versions.mdx
Normal 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.
|
||||
141
src/content/docs/docs/architecture/system-architecture.mdx
Normal file
141
src/content/docs/docs/architecture/system-architecture.mdx
Normal 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.
|
||||
153
src/content/docs/docs/architecture/teams-architecture.mdx
Normal file
153
src/content/docs/docs/architecture/teams-architecture.mdx
Normal 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 3–4 for pages and the
|
||||
activity feed.
|
||||
131
src/content/docs/docs/architecture/the-bridge.mdx
Normal file
131
src/content/docs/docs/architecture/the-bridge.mdx
Normal 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.
|
||||
161
src/content/docs/docs/modules/building-a-module.mdx
Normal file
161
src/content/docs/docs/modules/building-a-module.mdx
Normal 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.
|
||||
115
src/content/docs/docs/modules/installing-modules.mdx
Normal file
115
src/content/docs/docs/modules/installing-modules.mdx
Normal 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.
|
||||
103
src/content/docs/docs/modules/module-lifecycle.mdx
Normal file
103
src/content/docs/docs/modules/module-lifecycle.mdx
Normal 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.
|
||||
104
src/content/docs/docs/modules/testing-and-release.mdx
Normal file
104
src/content/docs/docs/modules/testing-and-release.mdx
Normal 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.
|
||||
107
src/content/docs/docs/modules/the-integration-kit.mdx
Normal file
107
src/content/docs/docs/modules/the-integration-kit.mdx
Normal 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
|
||||
```
|
||||
171
src/content/docs/docs/modules/the-module-api.mdx
Normal file
171
src/content/docs/docs/modules/the-module-api.mdx
Normal 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.
|
||||
114
src/content/docs/docs/modules/the-module-manifest.mdx
Normal file
114
src/content/docs/docs/modules/the-module-manifest.mdx
Normal 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.
|
||||
103
src/content/docs/docs/modules/the-module-system.mdx
Normal file
103
src/content/docs/docs/modules/the-module-system.mdx
Normal 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.
|
||||
73
src/content/docs/docs/reference/bridge-cfg.mdx
Normal file
73
src/content/docs/docs/reference/bridge-cfg.mdx
Normal 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.
|
||||
70
src/content/docs/docs/reference/canonical-documents.mdx
Normal file
70
src/content/docs/docs/reference/canonical-documents.mdx
Normal 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.
|
||||
66
src/content/docs/docs/reference/environment-variables.mdx
Normal file
66
src/content/docs/docs/reference/environment-variables.mdx
Normal 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.
|
||||
104
src/content/docs/docs/reference/event-catalog.mdx
Normal file
104
src/content/docs/docs/reference/event-catalog.mdx
Normal 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.
|
||||
85
src/content/docs/docs/reference/http-api.mdx
Normal file
85
src/content/docs/docs/reference/http-api.mdx
Normal 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.
|
||||
85
src/content/docs/docs/reference/installer-cli.mdx
Normal file
85
src/content/docs/docs/reference/installer-cli.mdx
Normal 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.
|
||||
68
src/content/docs/docs/reference/sidecar-toml.mdx
Normal file
68
src/content/docs/docs/reference/sidecar-toml.mdx
Normal 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
190
src/data/reference.mjs
Normal 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',
|
||||
};
|
||||
Reference in New Issue
Block a user