docs(website): close phase 3 — slice 5, the fragment obligation, and the rust dry run
Records the slice that closes the extraction, and adds the two documents phase 3 owed: docs/modules/uo/ and the module-rust dry run. **The slice found an obligation neither repo had built.** MODULE_API.md §2.8 and §6.1a settle the OpenAPI fragment in detail — a module ships one, core merges the fragments of started modules into /api/docs.json. Neither half existed, so the 72 URLs module-uo serves were in no spec at all. §2.8 and §6.1a now record what was built, including the four things settled while building it: the filename is fixed rather than declared, a module namespaces what it DEFINES and references core's shared schemas by core's name, the generator derives its prefixes from the module's own register() call, and swagger-autogen's diagnostics have to be captured because it reports a broken annotation and then prints Success. **§5.3 gains the design decision the frozen manifest actually made:** it is a SUBTRACTION, not a prefix filter. Generating the manifest without the module and then with it answers "what does the module serve" AND "did core lose anything", and the second is the one §1.2 promises to the shipped Android app. A module that shadowed a core route cannot appear as an addition anywhere. **BACKEND_DESIGN.md §4.0.1** is new: /api/docs.json is assembled per request, the two generated artifacts are core's alone, and the route count was still 228. **docs/modules/** is new, per §2.10 (module documentation aggregates here, not in module repos): docs/modules/uo/README.md orients a reader on what module-uo serves, owns and needs from an operator, and links out to the feature docs that already existed rather than restating them. **docs/modules/rust-dryrun.md** is phase 3's fourth acceptance criterion. A written, deliberately unimplemented module for Rust — chosen because it wipes monthly, runs several servers rather than one shard, identifies by Steam, and ships RCON so there is no sidecar to write. The contract generalises: same manifest, same seven registration calls, same schema rules, and six of the UI kit's seven members wanted by a game with nothing in common with the one the kit was curated from. It found one real gap — **a module cannot register an identity provider**, and "Sign in with Steam" is what a Rust community expects. Recorded as the first candidate for a future MODULE_API_VERSION bump rather than bolted on: an identity provider participates in session creation, and §2.7's link-only SSO policy has to survive it. Also: website-README.md refreshed from the repo (it was several changes stale), and three settled decisions added (18-20). Pairs with Module-uo#6 and website#141. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
262
modules/rust-dryrun.md
Normal file
262
modules/rust-dryrun.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# `module-rust` — a dry run
|
||||
|
||||
**Phase 3's fourth acceptance criterion** ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md)
|
||||
§2.7). A written design for a module serving a **Rust** (Facepunch) community, taken far enough to
|
||||
find out whether the contract generalises past the game it was extracted from — **and deliberately
|
||||
not implemented.** A contract validated only against the module it was carved out of has not been
|
||||
validated.
|
||||
|
||||
The exercise is honest if it finds something. It found four things, one of which is a gap in the
|
||||
contract that a real second module would hit on its first day. They are in *Findings* at the end; the
|
||||
design comes first, because a finding is only worth anything with the design that produced it.
|
||||
|
||||
Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions:
|
||||
it **wipes** every month, a community runs **several servers** rather than one shard, its identity
|
||||
is **Steam**, and its server speaks a **protocol nobody has to write** — RCON over WebSocket, built
|
||||
in. If the contract survives that, "game-agnostic" means something.
|
||||
|
||||
> Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is
|
||||
> normative; this document only *uses* it.
|
||||
|
||||
---
|
||||
|
||||
## 1. The manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "rust",
|
||||
"name": "Rust",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.3.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
"purge": "server/db/purge.sql",
|
||||
"mounts": {
|
||||
"public": ["/rust"],
|
||||
"admin": ["/rust"],
|
||||
"player": ["/rust"]
|
||||
},
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["servers", "wipes", "leaderboards", "map", "killfeed", "teams"]
|
||||
}
|
||||
```
|
||||
|
||||
One prefix per tier, named for the module rather than for a feature — the opposite of module-uo's
|
||||
`/shard` + `/atlas` + `/uo-link`, and the better choice for anything new. Module-uo's prefixes are
|
||||
what they are because §1.2 froze the URLs core already served; a module written today has no such
|
||||
debt and should claim one obvious segment. It also sidesteps the collision surface entirely:
|
||||
`/shard` is a word a second game might want, `/rust` is not.
|
||||
|
||||
## 2. The server half
|
||||
|
||||
```js
|
||||
module.exports = function register(ctx, api) {
|
||||
core.init(ctx)
|
||||
|
||||
const publicRouter = require('./router/public/rust.router')
|
||||
const adminRouter = require('./router/admin/rust.router')
|
||||
const playerRouter = require('./router/player/rust.router')
|
||||
const userExt = require('./router/admin/usersRust.router')
|
||||
|
||||
api.registerRoutes({
|
||||
public: { '/rust': publicRouter },
|
||||
admin: { '/rust': adminRouter },
|
||||
player: { '/rust': playerRouter },
|
||||
})
|
||||
|
||||
api.registerExtension('admin.users.detail', userExt)
|
||||
|
||||
api.registerNotificationStreams([
|
||||
{ id: 'rust.wipe', label: 'Server wipes',
|
||||
description: 'A server wiped — new map, new seed, everything reset.',
|
||||
personal: false, requiresLinkedAccount: false },
|
||||
{ id: 'rust.raid', label: 'Base raided',
|
||||
description: 'Your base took damage while you were offline.',
|
||||
personal: true, requiresLinkedAccount: true },
|
||||
])
|
||||
|
||||
api.registerAnnounceLeg({
|
||||
leg: 'rust.ingame',
|
||||
label: 'In-game chat',
|
||||
dispatch: (post) => rcon.say(`[NEWS] ${post.title} — ${ctx.site.baseUrl}/news/${post.slug}`),
|
||||
classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }),
|
||||
})
|
||||
|
||||
api.onBoot(async () => { await rcon.connectAll() })
|
||||
api.onShutdown(async () => { await rcon.closeAll() })
|
||||
}
|
||||
```
|
||||
|
||||
Everything above is a call the contract already has, used the way module-uo uses it. Two details are
|
||||
worth pointing at:
|
||||
|
||||
- **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven
|
||||
bare stream ids are allowlisted because they were in `notification_subs` before the rule existed
|
||||
(§6.5); a new module gets the rule, and the rule is exactly right.
|
||||
- **The announce leg goes to in-game chat over RCON**, which is a one-shot delivery with retry —
|
||||
`registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that
|
||||
has nothing in common with the one it was drawn for.
|
||||
|
||||
### Tables
|
||||
|
||||
`rust_servers`, `rust_wipes`, `rust_players`, `rust_player_stats`, `rust_teams`, `rust_events`,
|
||||
`rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment.
|
||||
|
||||
**Every table that holds gameplay data carries a `wipe_id`.** That is the whole shape of the game in
|
||||
one column: a leaderboard means "since the last wipe", a base means "on this map", and a player's
|
||||
stats are per-wipe with an all-time rollup kept separately. It has no bearing on the contract —
|
||||
core replays the fragment and never looks inside — but it is the first thing a UO-shaped mental
|
||||
model gets wrong, and it is worth writing down for whoever builds this.
|
||||
|
||||
### Talking to the game
|
||||
|
||||
**No sidecar.** Rust ships RCON over WebSocket, so the module dials the server directly with the
|
||||
token an admin saved, encrypted at rest through `ctx.secretBox`.
|
||||
|
||||
This is the sharpest test of whether the module system's boundary is drawn in the right place, and it
|
||||
passes: core has no opinion about how a module reaches its game. What core owns is that the module
|
||||
never blocks a request on it, that its secrets are encrypted, and that a game being down degrades to
|
||||
a page saying so. The *shard-dials-out* invariant that shapes
|
||||
[`../link/PLAN.md`](../link/PLAN.md) is a property of ServUO — a game engine with no remote-control
|
||||
surface, whose plugin must not stall on a socket — not of the platform. A game that ships RCON
|
||||
already answers the question the sidecar exists to answer.
|
||||
|
||||
The Integration Kit ([`MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md) §2.11) should say this
|
||||
plainly, or its second reader will build a sidecar they did not need.
|
||||
|
||||
## 3. The client half
|
||||
|
||||
```js
|
||||
registry.registerRoutes('rust', {
|
||||
public: [
|
||||
{ path: 'servers', element: <Servers /> },
|
||||
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||
{ path: 'servers/:id/map', element: <MapView /> },
|
||||
{ path: 'leaderboards', element: <Leaderboards /> },
|
||||
{ path: 'wipes', element: <WipeSchedule /> },
|
||||
],
|
||||
player: [
|
||||
{ path: 'account', element: <LinkedAccount /> },
|
||||
{ path: 'stats', element: <MyStats /> },
|
||||
],
|
||||
admin: [
|
||||
{ path: 'servers', element: <AdminServers />, gate: { roles: ['admin'] } },
|
||||
{ path: 'ops', element: <AdminOps />, gate: { roles: ['admin', 'moderator'] } },
|
||||
],
|
||||
})
|
||||
|
||||
registry.registerNav('rust', {
|
||||
area: 'public',
|
||||
items: [
|
||||
{ label: 'Servers', to: '/rust/servers', group: 'Play', order: 10, icon: ServerIcon },
|
||||
{ label: 'Leaderboards', to: '/rust/leaderboards', group: 'Play', order: 20, icon: TrophyIcon,
|
||||
feature: 'leaderboards' },
|
||||
{ label: 'Wipe schedule', to: '/rust/wipes', group: 'Play', order: 30, icon: CalendarIcon },
|
||||
],
|
||||
})
|
||||
|
||||
registry.registerFeatureProvider('rust', 'rust', useRustFeatures)
|
||||
registry.registerExtension('rust', 'admin.users.detail', LinkedSteamAccounts)
|
||||
```
|
||||
|
||||
`Play` is a group core does not have; §3.3 appends an unknown group rather than dropping the items,
|
||||
so this works and lands at the end of the nav — where an operator can move it, because a module row
|
||||
is an ordinary row once it is interleaved.
|
||||
|
||||
The pages need `PublicLayout`, `PageHeader`, the three `PageState` components, `useAsync` and
|
||||
`useAuth`: **six of the kit's seven members**, and the seventh (`useSite`) on the wipe-schedule page
|
||||
for the site's timezone. A second game, unrelated to the first, wanting exactly what the kit
|
||||
contains is the strongest evidence available that §3.4 was curated at the right altitude.
|
||||
|
||||
The map view is the one page that wants something the kit does not have — a pan/zoom canvas. It
|
||||
bundles one, which is the answer §3.4 already gives ("everything else a module bundles itself"), and
|
||||
it costs the chunk about 40 KB.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. A module cannot register an identity provider — and Rust's identity is Steam
|
||||
|
||||
The gap. Module-uo proves account ownership with an in-game `[link` command that issues a one-time
|
||||
code; the website confirms it with the sidecar. Nothing about that needs core's auth layer, so
|
||||
nothing in `api` ever needed to touch it.
|
||||
|
||||
**Rust's answer is Steam OpenID**, and every Rust community expects "Sign in with Steam". Core has an
|
||||
SSO layer — `authProviders`, `userIdentities`, OAuth2/OIDC providers behind a registry — and
|
||||
[`MODULE_API.md`](../website/MODULE_API.md) §2.4 offers a module no way in. There is no
|
||||
`registerAuthProvider`, and §2.7 forbids reaching for one.
|
||||
|
||||
A Rust module can still ship: it can copy the UO shape, issuing a code in-game and matching it on
|
||||
the website. That works, it is one screen worse, and it leaves the community's obvious expectation
|
||||
unmet.
|
||||
|
||||
**This is not a defect in what was built** — it is the boundary of what was specified, found exactly
|
||||
where a dry run is supposed to find it. It is worth stating precisely, because whoever adds it has to
|
||||
answer a question the current policy already has an opinion about: **SSO is link-only by design**, an
|
||||
external identity must already be linked to an existing account, and identities are never
|
||||
auto-provisioned. A Steam provider a module registers must inherit that, not route around it. It is
|
||||
also *not* a small addition: an identity provider participates in session creation, which is the one
|
||||
part of core a module must never be able to weaken.
|
||||
|
||||
Recommendation: leave it out of v1 of the contract and record it here as the first candidate for
|
||||
`MODULE_API_VERSION` 1.4 or 2.0, specified deliberately rather than bolted on when someone needs it.
|
||||
|
||||
### 2. "One module, one game" is not the same as "one module, one server"
|
||||
|
||||
A UO community runs one shard. A Rust community runs four or five, wipes them on different
|
||||
schedules, and every page is a per-server view.
|
||||
|
||||
The contract is silent on this, and silence turns out to be right: multiplicity lives entirely in the
|
||||
module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
|
||||
and state machine are per-**module**, and none of them wanted to be per-server.
|
||||
|
||||
Worth recording only because it looks like a problem until you try it — and because it is the shape
|
||||
that would have broken a contract designed around "the shard" as a singular noun. The phase-2
|
||||
inversions that removed core's opinions about game content (the push catalog, the announce legs,
|
||||
`mapEvent` dropped) are why it does not.
|
||||
|
||||
### 3. The event catalog generalises; the *retention* assumption does not
|
||||
|
||||
`rust_events` is the twin of `shard_events`, and the ingest shape carries over unchanged.
|
||||
|
||||
What does not carry over is that a UO event log grows forever while a Rust one is **truncated every
|
||||
wipe**. That is module-internal — but it lands on something core does own: the schema fragment
|
||||
**must not** be where that truncation happens. §2.6's leading-verb allowlist (`CREATE`, `ALTER`,
|
||||
`INSERT`, `UPDATE`) already forbids `DELETE` and `TRUNCATE` in a fragment, precisely because the
|
||||
fragment is replayed on **every boot** and would empty the table each restart.
|
||||
|
||||
So a wipe is a runtime operation on a module route, not a schema one. The allowlist was written for a
|
||||
different reason — the phase-2 note says "the file replays every boot, so TRUNCATE/DELETE would empty
|
||||
a table each restart" — and it correctly forbids the first mistake this module's author would make.
|
||||
A rule that catches a case it was not written for is a rule at the right altitude.
|
||||
|
||||
### 4. `capabilities` earns its keep the moment there are two modules
|
||||
|
||||
With one module, `capabilities` reads like decoration — core never interprets one, and the SPA knows
|
||||
what it registered. With two, it is the only thing a *client* can ask.
|
||||
|
||||
The Android app is the case: it feature-detects against `GET /api/v1/public/modules` and must render
|
||||
a site whose module it has never heard of. `["servers", "wipes", "leaderboards"]` tells it there is
|
||||
nothing shard-shaped here without it having to know what `rust` means, and §2.9's rule — treat an
|
||||
unknown capability as absent, never infer a route from one — is what keeps that from becoming a
|
||||
second, worse route table.
|
||||
|
||||
No change needed. Recorded because the design decision looked over-engineered with one module and is
|
||||
load-bearing with two.
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**The contract generalises.** A second game, chosen for how little it shares with the first, is
|
||||
served by the same `module.json`, the same seven registration calls, the same schema-fragment rules,
|
||||
the same client registry and the same UI kit — with one genuine gap (identity providers), one
|
||||
non-issue that looks like a gap (multiple servers), and two places where a rule written for one
|
||||
reason turns out to cover another.
|
||||
|
||||
The gap is worth having found before something was built on top of it, which is what a dry run is
|
||||
for. What it does **not** establish is that someone outside this org could build this module from the
|
||||
documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested
|
||||
until a person who did not write any of this does it.
|
||||
97
modules/uo/README.md
Normal file
97
modules/uo/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# module-uo — the Ultima Online module
|
||||
|
||||
Everything specific to Ultima Online that the website serves. Code:
|
||||
[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo). Module id `uo`,
|
||||
installed at `modules/uo/` on the website's modules volume.
|
||||
|
||||
This page is **orientation**: what the module is, what it serves, what it owns, and what an operator
|
||||
has to know. It is not a second copy of the contract — [`../../website/MODULE_API.md`](../../website/MODULE_API.md)
|
||||
is normative for anything about how a module and core fit together, and the module's own repo is
|
||||
authoritative for its file layout. Where this page and either of those disagree, they win.
|
||||
|
||||
Module documentation aggregates here rather than in module repos
|
||||
([`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) §2.10), so the feature docs that describe what
|
||||
these routes *mean* are the ones that already existed and did not move:
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
|
||||
| [`SPAWN_ATLAS.md`](../../website/SPAWN_ATLAS.md) | The bestiary / spawn atlas, parsed from the shard's own ServUO tree |
|
||||
| [`MARKETPLACE.md`](../../website/MARKETPLACE.md) | The player-vendor index |
|
||||
| [`CLILOCS.md`](../../website/CLILOCS.md) | UO's id → name table |
|
||||
| [`UOFIDDLER.md`](../../website/UOFIDDLER.md) | Operator runbook for extracting the cliloc table and creature art |
|
||||
| [`../../link/PLAN.md`](../../link/PLAN.md), [`../../link/INTEGRATION.md`](../../link/INTEGRATION.md) | The wire protocol this module speaks to the sidecar |
|
||||
|
||||
---
|
||||
|
||||
## What it serves
|
||||
|
||||
**72 URLs**, frozen in the module's own
|
||||
[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json)
|
||||
and documented in its
|
||||
[`swagger-fragment.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/swagger-fragment.json),
|
||||
which core merges into `/api/docs.json` while the module is running.
|
||||
|
||||
| Mount | Tier | What |
|
||||
|---|---|---|
|
||||
| `/api/v1/public/shard` | public | Status, activity feed, economy, presence, houses/IDOC, champs, guilds, governors, points boards, the player-vendor market, the ruleset, and the SSE event stream |
|
||||
| `/api/v1/public/atlas` | public | The bestiary: creatures, regions, landmarks, champion altars, and what is loaded |
|
||||
| `/api/v1/admin/shard` | admin | Shard ops (kick/ban/broadcast/pages), the visibility config, atlas and cliloc imports, market admin, account links |
|
||||
| `/api/v1/admin/uo-link` | admin | The sidecar connection config, live status, and the town crier |
|
||||
| `/api/v1/player/shard` | player | A player's own linked accounts: rosters, character sheets, vendors, sales |
|
||||
| `/api/v1/admin/users/:id/shard/*` | admin | Six routes filling core's `admin.users.detail` **extension slot** — a module's routes hanging off a *core* resource, since core owns the user |
|
||||
|
||||
Every URL is byte-identical to the one core served before the extraction. That is the whole point of
|
||||
moving the code and not the paths: the shipped Android app calls
|
||||
`POST /api/v1/admin/shard/kick`, the Discord bot reads `/api/v1/public/shard/*`, and neither knows a
|
||||
module answers now.
|
||||
|
||||
**In the SPA**: twelve public pages under `/uo/*`, the admin views under `/admin/uo/*`, the player
|
||||
views under `/player/uo/*`, three extension-slot fills, and the nav rows for all of them, interleaved
|
||||
into core's nav so an operator can reorder, relabel or hide them like any other row.
|
||||
|
||||
## What it owns
|
||||
|
||||
- **27 database tables** — 26 `shard_*` plus `uo_link_config`. Created by an idempotent
|
||||
`schema.sql` fragment core replays on every boot, after its own schema. The `shard_`/`uo_link_`
|
||||
prefixes are **grandfathered** ([`MODULE_API.md`](../../website/MODULE_API.md) §6.5): the rule for
|
||||
a new module is `<id>_`, and these predate it.
|
||||
- **Seven push notification streams** and the announce leg `towncrier`, likewise grandfathered —
|
||||
they are stored in `notification_subs` and `announce_job_legs.leg` and read by the shipped Android
|
||||
app, so renaming them would be a data migration plus a client break.
|
||||
- **Two settings rows**: `game_account_signup` and the shard's protocol pin.
|
||||
- **The public-safety filter.** Which shard event kinds may reach the *public* SSE stream is decided
|
||||
here, not in core — the kinds, the streams and the filter are one file that moves together.
|
||||
|
||||
## For an operator
|
||||
|
||||
**Installing.** A release is `module-uo-<version>.tar.gz` plus a manifest carrying its `sha256`.
|
||||
Unpack it as `modules/uo/` on the website's modules volume (or use the admin Modules screen when
|
||||
phase 4 lands) and restart. **You never build anything** — the client chunk is prebuilt and the one
|
||||
runtime dependency ships inside the tarball.
|
||||
|
||||
**Connecting it to a shard.** The module needs the
|
||||
[uo-link sidecar](https://gitea.whitlocktech.com/RunicGateway/link) running next to the ServUO
|
||||
shard. Deploy that with the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) —
|
||||
[`../../installer/INSTALL.md`](../../installer/INSTALL.md) is the operator guide — and paste the four
|
||||
values it prints into **Admin → Shard**. The token is encrypted at rest and write-only in the API.
|
||||
|
||||
**Nothing requires the shard to exist.** With no sidecar configured the site renders normally and
|
||||
shows the shard offline. That is the same bargain the module system makes one level up: a module
|
||||
that fails to load never takes the site down.
|
||||
|
||||
**Environment variables** — four, all optional, all read by the module and documented in its README:
|
||||
`UOLINK_BASE_URL`, `UOLINK_WS_URL`, `UOLINK_PROTOCOL`, `TOWNCRIER_DURATION_SEC`. They live in the
|
||||
Compose `.env`, because that is what reaches the container.
|
||||
|
||||
## Compatibility
|
||||
|
||||
`module.json` declares a `coreApi` semver range, checked at boot against core's
|
||||
`MODULE_API_VERSION`. A mismatch fails **loudly** — the module is marked `startup_failed` and the
|
||||
site comes up without it. This is a separate number from `PROTOCOL_VERSION`, which versions the
|
||||
shard wire and says nothing about a website module.
|
||||
|
||||
The module's CI clones core at a **pinned** ref ([`MODULE_API.md`](../../website/MODULE_API.md) §5.3)
|
||||
to generate its frozen manifest. Bumping that pin is a deliberate commit that says which core the
|
||||
module was last proved against — not a tracking reference that turns core's unrelated changes into
|
||||
red Xes here.
|
||||
Reference in New Issue
Block a user