Reviewed-on: #2
Module-Rust
The Rust module for the Runic Gateway platform: everything that
makes a Runic Gateway site a site for Rust. It installs into a website core as
modules/rust/ and is the platform's second game module, after
Module-uo.
It is also the first module built from the Integration Kit rather than extracted from the website — which makes it the kit's acceptance test from the inside.
The repository name is not the module id. This ships a module whose id is rust, because the
contract requires id to equal the directory core loads it from (modules/rust/), and that id is
the prefix of every table and every mount.
What it is, in one diagram
Rust server + Oxide (RunicGateway/Rust-Plugins)
│ loopback TCP, the plugin dials out
▼
rust-link sidecar (RunicGateway/Rust-Link) one per game server
│ HTTPS + WebSocket, bearer token
▼
this module, inside a website core one client per server
│ same-origin JSON
▼
browser · Android app
One server, one sidecar. A community running six Rust servers runs six pairs and configures six rows here; the website core never learns there is more than one.
What ships today
| Surface | Route |
|---|---|
| Public | GET /api/v1/public/rust/servers — every server and what it last reported |
| Player | GET /api/v1/player/rust/servers — the same, on the authenticated tier |
| Admin | GET/PUT/DELETE /api/v1/admin/rust/servers and POST …/:id/test |
| Page | /rust/servers |
Two tables, rust_servers (configuration) and rust_server_state (what each sidecar reported).
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications, events, the live map, Discord commands — arrives phase by phase. Nothing is registered before it has something behind it: a declared trigger nothing emits and a declared slot nothing fills are both surfaces an operator can configure and then wait on, which is worse than an absent one.
Build and check
npm ci --prefix server && npm test --prefix server
npm run check:imports --prefix server
npm run check:bundle --prefix server
npm run check:swagger --prefix server
npm ci --prefix client && npm run build --prefix client
npm run check:externals --prefix client && npm test --prefix client
Build the client BEFORE running its tests — two of them read the built chunk and skip when there is none, so a run in the other order passes while asking nothing about the artifact that ships.
Regenerate the OpenAPI fragment whenever a route or an annotation changes:
npm run swagger --prefix server # writes swagger-fragment.json; commit it
.gitea/workflows/pr-checks.yml runs all of the above on every pull request, plus one job this
machine cannot run on its own: frozen-manifest clones core at the sha pinned in
ci/core-ref.json, generates its route table without this module and then with
it, and takes the difference. That difference is the URL surface this module serves — checked
against the committed routes.manifest.json, against the OpenAPI fragment
in both directions, and against the rule that a module may only add. It is the only thing that
can see whether /rust collides with one of the routes core mounts at a tier root (/status,
/version), which the loader's own collision probe cannot find.
How it reaches an operator
An operator never builds anything. A release is not source: it is the directory core's loader
expects at modules/rust/, already assembled — the prebuilt client chunk, the schema fragment and
the OpenAPI fragment, packed as they will be unpacked.
Every merge to main carrying a releasable commit publishes a bundle. The next version is
computed from conventional-commit subjects since the newest v* tag, as in link, installer and
Module-uo: feat!: or BREAKING CHANGE is a major, feat: a minor, fix: or perf: a patch,
and a main that gained none of those cuts no release. The number that ships is the tag, and CI
writes it into the module.json inside the bundle. module.json's version survives as a floor:
name a version there above the newest tag and that version releases, which is how you overrule the
subjects. For a change with nothing releasable behind it — a widened coreApi, a new mount, a
capability — run the Release workflow by hand (Actions → Release → Run workflow).
Each release carries:
| Asset | What it is |
|---|---|
module-rust-<version>.tar.gz |
the directory core expects at modules/rust/, already assembled |
module-rust-<version>.json |
the install manifest: id, version, coreApi, the artifact's URL, size and sha256 |
SHA256SUMS |
the same hash, in the shape every other repo here publishes |
Releases are unsigned; the sha256 is the trust anchor, and the website verifies it before
unpacking. That is the model installer's bundles already use, and a second trust model would be a
second thing to get right.
The tarball is assembled from an include list (ci/bundle.json), never an
exclude list — an exclude list ships whatever it forgot. Tests, scripts, client/src, ci/ and the
dev dependencies are not in it. It carries no node_modules, because the shipped half declares
no runtime dependencies: everything it needs arrives on ctx. npm run check:bundle holds both
halves of that — that the list still covers every file server/index.js can reach, and that no
runtime dependency has appeared without the release learning to pack it.
Install it into a core
From a release, which is the supported path: in Admin → Modules, paste the URL of that release's
module-rust-<version>.json, and restart when the panel offers. Core fetches the manifest, checks
every URL and redirect hop against its own host allowlist, streams the artifact under a byte cap
while hashing it, verifies the sha256, inspects the archive in full before unpacking it to a
temporary directory, and only then moves it into modules/rust/. Nothing is written into the
modules directory until every check has passed. The allowlist must contain
gitea.whitlocktech.com — it is seeded from MODULE_SOURCE_HOSTS on a fresh install and is
DB-owned from then on, edited on that same screen. An empty allowlist forbids every install rather
than permitting all of them.
From a working tree, for development: copy the whole tree to <website>/modules/rust/ and
restart. Copy, do not symlink — the loader lists directory entries and asks each whether it is a
directory; a symlink answers no and the module is skipped in complete silence.
Either way, the module appears when the process restarts: the volume is read at require time.
Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar
printed on first start (rust-link-sidecar --print-config). The token is write-only — it is
stored encrypted through core's own secret box and never returned to any client; the panel reports
only whether one is set.
POST /api/v1/admin/rust/servers/:id/test probes a sidecar and reports what came back in one word.
That is the route that tells a wrong URL from a wrong token from a mismatched protocol version —
all three present as "the site says my server is offline" and each has a different fix.
The protocol is a contract
PROTOCOL_VERSION in server/sidecarClient.js is sent on every request as X-RustLink-Version,
and a sidecar speaking a different one answers 409 rather than serving something this module will
mis-parse. It must agree with the sidecar's own constant and with overlay.toml in the plugin repo.
Canonical spec:
docs/rust-link/PROTOCOL.md.
The module's own design of record is
docs/modules/rust/PLAN.md.
Licence
GPL-3.0-or-later. See LICENSE.md.