diff --git a/PLAN.md b/PLAN.md index d051438..0662524 100644 --- a/PLAN.md +++ b/PLAN.md @@ -237,7 +237,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r **Decisions after D13 are recorded where they were taken**, in the section describing the phase that raised them, rather than appended here — a decision is only re-litigated when its reasoning is -somewhere other than the thing it decided. The count of record is **sixty-nine**: +somewhere other than the thing it decided. The count of record is **seventy-one**: | # | Where | What it settled | |---|---|---| @@ -258,6 +258,7 @@ somewhere other than the thing it decided. The count of record is **sixty-nine** | D66 | §16, "How phase 13 built it" | The hero and footer show the Module API only; a protocol and a bundle belong to a game | | D67 | §16, "One rule the phases need" | A screenshot belongs to its game: a capture tagged with a game may carry its words, beside the game's name | | D68–D69 | §16, "How phase 14 built it" | A game's real example on a core page is a tagged, labelled block; *Verify the whole stack* stays in Getting started, game-neutral (amending D60) | +| D70–D71 | §16, "How phase 15 built it" | The Rust group is fifteen pages, one per admin screen where it can be; the Rust event kinds are checked against the module's own classification | --- @@ -2094,6 +2095,80 @@ repositories): - **The installer's `--help` says "Which game's shard side"** for `--game`, and `--site-url` names "Admin → Shard (uo-link)" for both games. +### How phase 15 built it + +Two decisions, taken by the org lead on 2026-10-22 before any page was written. The count of +record is now **seventy-one**. + +**D70 — the Rust group is fifteen pages.** D60 named the topics and not the pages. The group +follows the Ultima Online group's shape: an overview, then the journey (*Install the bridge*, +*Connect the site*, *Verify the bridge*), then one page per admin screen where there is one +(*Permissions and groups*, *Mod config from the site*, *Who sees who is online*, *Events, zones +and rewards*), then *Linking Steam accounts*, *Optional plugins*, *The live map* and +*Troubleshooting*, and three reference pages (*sidecar.toml*, *RunicGateway.json*, *Event +kinds*). *Who sees who is online* and *Optional plugins* got pages of their own rather than +being folded into the overview and the events page, because each has a screen or a trap of its +own — RaidableBases must be restarted, never reloaded. The alternative was ten longer pages. The +question put to the org lead called this option "14 pages" while listing fifteen; the list is +what was chosen. RunicNPC's subgroup is phase 16's. + +**D71 — the Rust event kinds are checked against the module's classification.** The plan said the +protocol-14 event list would be checked "against its sources". There were two candidates: +`PROTOCOL.md`, whose kinds are spread across tables from §8.4 to §20.12, and `Module-Rust` +`server/catalogue.js`, which holds three lists in code — public, staff, and the presence subset +of public. The second was chosen, for D39's reason: the visibility ladder is checked against the +module that enforces it, and so is this. The module's own test already holds its lists to +`PROTOCOL.md`, so the chain reaches the spec without this repo parsing prose tables. The page +shows three classes, and each is compared separately, so a kind moving from staff to public +upstream fails here twice. + +**`checkReference.mjs` gained three Rust sections**, as the plan said, rather than a new check: +rust-link's `sidecar.toml` (parsed from its serde structs by the same function as uo-link's, now +shared) and the six `RUSTLINK_…` variables that override it; the bridge plugin's +`RunicGateway.json`, read from the public fields of `PluginConfig` in `RunicGateway.cs`; and the +event kinds (D71). The canonical documents gained `OPERATING.md` and `CARBON.md` under Rust. + +**Every admin screen was walked on the walk site first** (D36), signed in as the walk admin: +Rust servers (Test, Edit, Chat titles, Optional mods), Rust permissions (a player, a group's four +tabs), Rust mod config (the tree and `RunicGateway.json`'s read-only keys), Rust visibility, Rust +zone presets, the event editor's action list, a user's Rust section, and the public server page's +feed, map and leaderboard. Nothing was saved. The NPC screens are phase 16's. Writing from the +walk and the source corrected three of my own drafts before they shipped: a reward does **not** +need a linked account (rewards go to Steam ids), the bundle ships **three** helpers, not two, and +the Test button prints the raw status (`protocol-mismatch`), not a phrase. + +**What the re-read found in this repository**, fixed here: + +- ***Verify the bridge* (Ultima Online) showed `"protocol":4`** in its `/health` sample, a + hard-coded version four protocols stale. Both games' samples now render the protocol from + `platform.json` through Starlight's ``, which is how a version gets into a code block + without being typed. +- ***Authentication* said "four tabs"** and never mentioned Steam, which core shipped with Module + API 1.17.0. It now has five, and a *Steam* section the Rust pages link to. +- ***Scheduled events* and *Events architecture* said the site cannot reach `EventsEnabled`.** + True for Ultima Online, false for Rust: an administrator can edit `RunicGateway.json`, that switch + included, from Admin → Rust mod config. Both pages now say so, the first as two tagged examples + (D68). + +**`checkFacts` went red mid-phase, as designed.** Installer v0.8.1 shipped on 2026-10-22 — the +game-neutral `--help` that phase 14 reported, fixed in `installer` #43 alongside `website` #221 for +the Teams panel — so `platform.json` moved to it. Its only change is that help text, which no page +quotes. + +**Findings in other repositories**, reported rather than fixed: + +- **`Module-Rust` admin copy shows decision numbers.** A group's *Servers* tab reads "unless it is + shared (D189)", and the *not imported yet* chip's tooltip ends "(D198)". Neither means anything + to an operator. +- **`Module-Rust`'s servers page reads "Last pushed: 1 player hold one."** — the plural verb is + used for one. +- **`Rust-Plugins`' `PluginConfig.EventsEnabled` comment says it "gates taking a lease only"**. + That stopped being true at protocol 9, when it began gating every world write. +- **`docs` `rust-link/INTEGRATION.md` predates the installer**: it says there is no installer + support for Rust and shows protocol-1 samples. **`rust-link/INSTALL.md`'s by-hand step names two + helpers**; the bundle ships three. The org lead approved a separate `docs` PR for these: + `docs` #370. + --- [api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md diff --git a/scripts/checkReference.mjs b/scripts/checkReference.mjs index a248646..6e961e9 100644 --- a/scripts/checkReference.mjs +++ b/scripts/checkReference.mjs @@ -7,6 +7,10 @@ * this is the machinery that makes writing the NAMES down safe anyway — the same bargain * checkQuickstart.mjs struck for the quickstart, applied to six more sources. * + * Phase 15 (PLAN.md §16) added the Rust group's three: rust-link's `sidecar.toml` and its + * environment overrides, the bridge plugin's `RunicGateway.json`, and the event kinds with the + * class each is served under (D71). + * * Each enumeration in `src/data/reference.mjs` is compared against its authority, read from * the repository that owns it over the Gitea API — never from a working tree, per §1's * process rule. Every comparison is a SET comparison in both directions: @@ -39,6 +43,10 @@ import { bridgeCfg, visibilityLadder, canonicalDocs, + rustSidecarConfig, + rustSidecarEnv, + rustPluginConfig, + rustEventKinds, } from '../src/data/reference.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); @@ -103,6 +111,45 @@ const cfgKeysOf = (text) => .filter(Boolean) .map((m) => m[1]); +/** + * A sidecar's TOML keys, parsed from its serde structs rather than from a sample file, + * because the sample is GENERATED by the binary on first run and no committed copy is + * authoritative. Each `pub name: T` inside a `struct XCfg` is one key, and the struct name + * gives the section. Both sidecars are written this way, so one parser serves both. + */ +function tomlKeysOf(configRs) { + const keys = []; + for (const m of configRs.matchAll(/struct\s+(\w+)Cfg\s*\{([\s\S]*?)\n\}/g)) { + const section = m[1].toLowerCase(); + for (const f of m[2].matchAll(/pub\s+(\w+)\s*:/g)) keys.push(`${section}.${f[1]}`); + } + return keys; +} + +/** The body of a C# class, by brace counting from its opening brace. */ +function classBody(source, name) { + const start = source.search(new RegExp(`class\\s+${name}\\b`)); + if (start < 0) return null; + const open = source.indexOf('{', start); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i); + } + return null; +} + +/** + * The quoted strings inside `const NAME = Object.freeze([ … ])`, comments removed first — + * the module's comments are prose, and prose has apostrophes. + */ +function frozenListOf(source, name) { + const m = source.match(new RegExp(`const\\s+${name}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\)`)); + if (!m) return null; + const code = m[1].replace(/\/\/.*$/gm, ''); + return [...code.matchAll(/'([^'\n]+)'/g)].map((x) => x[1]); +} + async function run() { if (!TOKEN) { console.error('checkReference: GITEA_TOKEN is not set. This check cannot run anonymously.'); @@ -122,13 +169,12 @@ async function run() { // Parsed from the serde structs rather than from a sample file, because the sample is // GENERATED by the binary on first run and no committed copy is authoritative. Each // `pub name: T` inside a `struct XCfg` is one key, and the struct name gives the section. - const configRs = await raw('link', 'sidecar/src/config.rs'); - const sidecarKeys = []; - for (const m of configRs.matchAll(/struct\s+(\w+)Cfg\s*\{([\s\S]*?)\n\}/g)) { - const section = m[1].toLowerCase(); - for (const f of m[2].matchAll(/pub\s+(\w+)\s*:/g)) sidecarKeys.push(`${section}.${f[1]}`); - } - compareSets('sidecar.toml', Object.keys(sidecarConfig), sidecarKeys, 'link main:sidecar/src/config.rs'); + compareSets( + 'sidecar.toml', + Object.keys(sidecarConfig), + tomlKeysOf(await raw('link', 'sidecar/src/config.rs')), + 'link main:sidecar/src/config.rs', + ); // ── 3. Installer commands ───────────────────────────────────────────────── const cliRs = await raw('installer', 'src/cli.rs'); @@ -175,6 +221,72 @@ async function run() { else fail(`canonical doc ${docPath}`, `not found in docs main (HTTP ${res.status})`); } + // ── 7. Rust: rust-link's sidecar.toml, and the variables that override it ── + // + // PLAN.md §16, phase 15. The same parser as uo-link's — both sidecars declare their file + // as serde structs — plus the environment overrides, which a Pterodactyl egg sets and an + // operator meets on the panel before they ever see the file. + const rustConfigRs = await raw('Rust-Link', 'sidecar/src/config.rs'); + compareSets( + 'rust-link sidecar.toml', + Object.keys(rustSidecarConfig), + tomlKeysOf(rustConfigRs), + 'Rust-Link main:sidecar/src/config.rs', + ); + compareSets( + 'rust-link environment variable', + Object.keys(rustSidecarEnv), + [...rustConfigRs.matchAll(/get\("(RUSTLINK_[A-Z_]+)"\)/g)].map((m) => m[1]), + 'Rust-Link main:sidecar/src/config.rs', + ); + for (const [envName, key] of Object.entries(rustSidecarEnv)) { + if (!(key in rustSidecarConfig)) fail(`rust-link environment variable ${envName}`, `says it overrides "${key}", which is not a key listed here`); + } + + // ── 8. Rust: the bridge plugin's RunicGateway.json ──────────────────────── + // + // The public fields of `PluginConfig`, which the framework serialises as the file. + const pluginCs = await raw('Rust-Plugins', 'overlay/oxide/plugins/RunicGateway.cs'); + const pluginBody = classBody(pluginCs, 'PluginConfig'); + if (!pluginBody) { + fail('RunicGateway.json', 'could not find class PluginConfig in Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs'); + } else { + compareSets( + 'RunicGateway.json', + Object.values(rustPluginConfig).flatMap((group) => Object.keys(group)), + [...pluginBody.matchAll(/public\s+[\w<>[\],]+\s+(\w+)\s*=/g)].map((m) => m[1]), + 'Rust-Plugins main:overlay/oxide/plugins/RunicGateway.cs PluginConfig', + ); + } + + // ── 9. Rust: the event kinds, and who may see each ──────────────────────── + // + // D71: checked against the module's own catalogue, because the module is what enforces + // the boundary (the same reasoning as check 5). Three comparisons, each a security + // statement: a kind listed public here must be public there, a presence kind must be one + // of the module's presence kinds, and a staff kind must be staff there. A kind moved from + // staff to public upstream fails this check twice over, which is the point. + const catalogue = await raw('Module-Rust', 'server/catalogue.js'); + const upPublic = frozenListOf(catalogue, 'PUBLIC_KINDS'); + const upStaff = frozenListOf(catalogue, 'STAFF_KINDS'); + const upPresence = frozenListOf(catalogue, 'PRESENCE_KINDS'); + if (!upPublic || !upStaff || !upPresence) { + fail('Rust event kinds', 'could not find PUBLIC_KINDS, STAFF_KINDS and PRESENCE_KINDS in Module-Rust main:server/catalogue.js'); + } else { + const hint = 'Module-Rust main:server/catalogue.js'; + compareSets('Rust presence kind', Object.keys(rustEventKinds.presence), upPresence, `${hint} PRESENCE_KINDS`); + compareSets( + 'Rust public kind', + Object.keys(rustEventKinds.public), + upPublic.filter((k) => !upPresence.includes(k)), + `${hint} PUBLIC_KINDS (less its presence kinds)`, + ); + compareSets('Rust staff kind', Object.keys(rustEventKinds.staff), upStaff, `${hint} STAFF_KINDS`); + for (const k of upPresence) { + if (!upPublic.includes(k)) fail(`Rust presence kind ${k}`, 'is a presence kind upstream but not a public one — the page explains presence as a narrowing of public, so re-read it'); + } + } + // ── Report ──────────────────────────────────────────────────────────────── if (failures.length === 0) { console.log(`checkReference: ${checked.length} enumeration check(s) passed against their sources.`); diff --git a/src/config/sidebar.mjs b/src/config/sidebar.mjs index 0fe21c5..1eeaca6 100644 --- a/src/config/sidebar.mjs +++ b/src/config/sidebar.mjs @@ -98,6 +98,26 @@ export const docsSidebar = [ { label: 'Shard event catalog', slug: 'docs/ultima-online/event-catalog' }, ], }, + { + label: 'Rust', + items: [ + { label: 'The Rust module', slug: 'docs/rust' }, + { label: 'Install the bridge', slug: 'docs/rust/install-the-bridge' }, + { label: 'Connect the site', slug: 'docs/rust/connect-the-site' }, + { label: 'Verify the bridge', slug: 'docs/rust/verify-the-bridge' }, + { label: 'Linking Steam accounts', slug: 'docs/rust/linking-steam-accounts' }, + { label: 'Permissions and groups', slug: 'docs/rust/permissions-and-groups' }, + { label: 'Mod config from the site', slug: 'docs/rust/mod-config' }, + { label: 'Who sees who is online', slug: 'docs/rust/who-sees-who-is-online' }, + { label: 'Events, zones and rewards', slug: 'docs/rust/events-zones-and-rewards' }, + { label: 'Optional plugins', slug: 'docs/rust/optional-plugins' }, + { label: 'The live map', slug: 'docs/rust/the-live-map' }, + { label: 'Troubleshooting', slug: 'docs/rust/troubleshooting' }, + { label: 'sidecar.toml', slug: 'docs/rust/sidecar-toml' }, + { label: 'RunicGateway.json', slug: 'docs/rust/runicgateway-json' }, + { label: 'Event kinds', slug: 'docs/rust/event-kinds' }, + ], + }, ]; /** @@ -174,4 +194,22 @@ export const plannedSidebar = { 'sidecar.toml', 'Shard event catalog', ], + // PLAN.md §16, D70 (phase 15). RunicNPC's subgroup lands in phase 16 (D61). + Rust: [ + 'The Rust module', + 'Install the bridge', + 'Connect the site', + 'Verify the bridge', + 'Linking Steam accounts', + 'Permissions and groups', + 'Mod config from the site', + 'Who sees who is online', + 'Events, zones and rewards', + 'Optional plugins', + 'The live map', + 'Troubleshooting', + 'sidecar.toml', + 'RunicGateway.json', + 'Event kinds', + ], }; diff --git a/src/content/docs/docs/administration/authentication.mdx b/src/content/docs/docs/administration/authentication.mdx index 1df190d..3be12c2 100644 --- a/src/content/docs/docs/administration/authentication.mdx +++ b/src/content/docs/docs/administration/authentication.mdx @@ -5,7 +5,7 @@ description: Local accounts and two-factor, SSO providers and the link-only poli import { Aside } from '@astrojs/starlight/components'; -**Admin → Authentication** has four tabs: Local Accounts, Google, Discord and Custom +**Admin → Authentication** has five tabs: Local Accounts, Google, Discord, Steam and Custom Providers. One session model sits behind all of them — a web cookie, a mobile bearer token and an SSO sign-in all produce the same session. @@ -35,6 +35,19 @@ account is still a stranger. Configuring Google here also unlocks **email delivery**, which reuses the same OAuth client — see [Notifications and email](/docs/administration/notifications-and-email/). +### Steam + +Steam signs people in with OpenID rather than OAuth, so it needs no client ID or secret — but +it does need a **Steam Web API key**, for the name and avatar Steam's sign-in does not carry, +and saving Steam as enabled without one is refused. It follows the same link-only policy, more +strictly: **Steam never creates an account**, whatever registration allows. A person may link +several Steam accounts, and each is listed on their account screen with its name, avatar and +Steam ID. + +A game module can also be told which Steam accounts a signed-in person has proved are theirs, +which is how a game that knows its players by Steam account can recognise a member. The Rust +module uses it — see [Linking Steam accounts](/docs/rust/linking-steam-accounts/). + ## Trusted devices A second factor that asks on every sign-in on the same laptop trains people to click diff --git a/src/content/docs/docs/administration/events.mdx b/src/content/docs/docs/administration/events.mdx index 89ecb36..7cc5327 100644 --- a/src/content/docs/docs/administration/events.mdx +++ b/src/content/docs/docs/administration/events.mdx @@ -141,8 +141,8 @@ restart puts every borrowed value back too. ## The game server has its own switches -They live in the bridge plugin's config on the game server's host, outside the site's reach, -and the site cannot turn them on. +They live in the bridge plugin's config on the game server's host, not in the site's +database, and no event can turn them on. **`EventsEnabled` is off by default**, in both games. Turning it on is consenting to the site changing and watching your world unattended. @@ -151,7 +151,14 @@ changing and watching your world unattended. `EventsEnabled` is a different switch from `AdminWriteEnabled`. Turning the admin plane on is consenting to staff moderation driven from a screen somebody is looking at; turning events on is consenting to unattended changes. One switch could not honestly express both. Every key -is in [`Bridge.cfg`](/docs/ultima-online/bridge-cfg/). +is in [`Bridge.cfg`](/docs/ultima-online/bridge-cfg/), which only the shard host can edit. + + + +`EventsEnabled` is in each server's [`RunicGateway.json`](/docs/rust/runicgateway-json/). An +administrator can edit it on the host, or from the site's +[Mod config](/docs/rust/mod-config/) screen, which reaches the game host's config files +through the bridge. Beside it sit the game's own ceilings — how much one call may place, how long something it diff --git a/src/content/docs/docs/administration/troubleshooting.mdx b/src/content/docs/docs/administration/troubleshooting.mdx index e2414b0..34a20a4 100644 --- a/src/content/docs/docs/administration/troubleshooting.mdx +++ b/src/content/docs/docs/administration/troubleshooting.mdx @@ -82,7 +82,7 @@ Two responses from a sidecar mean the same thing for every game: **`409`** is a mismatch (the sidecar refuses rather than mis-parsing), and **`401`** is the auth token. Each game's group in the sidebar has its own troubleshooting page with the exact commands: -[Ultima Online](/docs/ultima-online/troubleshooting/) today. +[Ultima Online](/docs/ultima-online/troubleshooting/) and [Rust](/docs/rust/troubleshooting/). ## Teams are missing diff --git a/src/content/docs/docs/architecture/events-architecture.mdx b/src/content/docs/docs/architecture/events-architecture.mdx index 2c32f05..d9eaf75 100644 --- a/src/content/docs/docs/architecture/events-architecture.mdx +++ b/src/content/docs/docs/architecture/events-architecture.mdx @@ -114,11 +114,13 @@ something must not take that path. are re-validated against the database on every request, so a demotion takes effect on the next click. -The game's switches are the layer the site cannot reach: they live in the bridge plugin's -own config on the game server's host — `EventsEnabled`, in both games — and are off out of -the box, and the -game's own ceilings **refuse rather than clamp** — because a silently shortened request leaves -the two halves disagreeing about what happened. +The game's switches are the layer outside the site's own database: they live in the bridge +plugin's own config on the game server's host — `EventsEnabled`, in both games — and are off +out of the box. No event and no role check can turn one on. On Rust an administrator can +still edit that file from the game module's config screen, which reaches the host's config +files through the bridge, so there it is a separate deliberate act rather than an unreachable +one. The game's own ceilings **refuse rather than clamp** — because a silently shortened +request leaves the two halves disagreeing about what happened.