docs(modules): the module-rust plan — 18 decisions of record and a 21-phase schedule #249
@@ -1,6 +1,6 @@
|
|||||||
# `module-rust` — the plan
|
# `module-rust` — the plan
|
||||||
|
|
||||||
**Status:** approved in outline 2026-09-15, not started. **Seventeen decisions of record, no open
|
**Status:** approved in outline 2026-09-15, not started. **Eighteen decisions of record, no open
|
||||||
questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and
|
questions.** Audited against the whole contract, not just the game-facing chapters (§7); the event and
|
||||||
engagement catalogues are §9 and §10.
|
engagement catalogues are §9 and §10.
|
||||||
|
|
||||||
@@ -380,6 +380,88 @@ probe cannot see all of core's** — several core endpoints are mounted at the t
|
|||||||
under a prefix. A noun from our own domain that equals the module id cannot collide, where
|
under a prefix. A noun from our own domain that equals the module id cannot collide, where
|
||||||
`/servers` or `/map` very well might.
|
`/servers` or `/map` very well might.
|
||||||
|
|
||||||
|
### R18 — plugin configuration is editable from the site, with a generated form and an auto-reload
|
||||||
|
|
||||||
|
**Decided 2026-09-15 (org lead).** An admin edits any loaded plugin's configuration from the website
|
||||||
|
and it reloads automatically. Two tiers:
|
||||||
|
|
||||||
|
- **Base:** the site reads `oxide/config/<Plugin>.json` and **generates a form from the values
|
||||||
|
themselves** — a boolean becomes a toggle, a number a numeric field, a string a text box, an array
|
||||||
|
a list, a nested object a group. It is derived at read time, so **it works for whatever plugins
|
||||||
|
happen to be installed**, including ones added or removed after we shipped.
|
||||||
|
- **Advanced:** raw JSON editing for anything the generated form cannot express.
|
||||||
|
|
||||||
|
This is the same posture as R2 — the site as the authority over the game host — but a different
|
||||||
|
*shape*. R2 is continuously reconciled state pushed on every connect; this is **request/reply,
|
||||||
|
on demand** ([kit][kit] ch. 3 §2a). Do not build it on the permission mirror.
|
||||||
|
|
||||||
|
**The mechanics Oxide gives us, all verified 2026-09-15:** configs live at
|
||||||
|
`oxide/config/<PluginName>.json`; `oxide.reload <name>` rereads one; and **`OnPluginLoaded` /
|
||||||
|
`OnPluginUnloaded` are real hooks** in the Server category, so *whether a reload actually succeeded
|
||||||
|
is observable* rather than assumed.
|
||||||
|
|
||||||
|
#### The trap that would silently corrupt every float
|
||||||
|
|
||||||
|
**JavaScript cannot tell `1` from `1.0`, and Oxide configs deserialize into typed C# classes.**
|
||||||
|
`JSON.parse('{"Rate":1.0}')` yields the number `1`, and `JSON.stringify` writes it back as `1` — so
|
||||||
|
a naive read-modify-write of a config file **silently rewrites every whole-numbered float as an
|
||||||
|
integer**, on fields nobody touched. Newtonsoft may coerce it, or may throw, and a throw at load
|
||||||
|
means the plugin does not come back.
|
||||||
|
|
||||||
|
So: **never parse the whole document, mutate, and re-serialize.** Edit the file *textually* — a
|
||||||
|
surgical replacement of the edited key's value — or use a parser that preserves number literals. The
|
||||||
|
fields at risk are exactly the ones a Rust server tunes: gather rates, multipliers, scales.
|
||||||
|
|
||||||
|
#### Five more limits of inferring a schema from values
|
||||||
|
|
||||||
|
Worth stating because the feature's whole promise is that it works without knowing the plugin:
|
||||||
|
|
||||||
|
- **Empty arrays and `null` carry no type.** Nothing can be inferred; render them advanced-only.
|
||||||
|
- **Enum-like strings are indistinguishable from free text.** There is no allowed-value set to read,
|
||||||
|
so a string field is a text box and cannot be validated.
|
||||||
|
- **There are no descriptions, no minimums and no maximums.** The key name is the entire label —
|
||||||
|
which is survivable because Oxide convention favours readable keys (BetterChat really does ship
|
||||||
|
`"Maximal Titles"` and `"Reverse Title Order"`).
|
||||||
|
- **Nested objects and arrays of objects need recursion**, a depth limit, and a fall-back to raw JSON
|
||||||
|
past it.
|
||||||
|
- **The file after a reload may not be what we wrote.** Oxide merges missing defaults and saves, so
|
||||||
|
re-read after reloading rather than assuming our write is the current state.
|
||||||
|
|
||||||
|
#### Safety, and why it needs more than the usual
|
||||||
|
|
||||||
|
**A bad config does not fail the write — it fails the next load, and the plugin stays down.** Since
|
||||||
|
R6 and R17 make four plugins *required*, a broken ZoneManager config takes event participation with
|
||||||
|
it. So the write path is:
|
||||||
|
|
||||||
|
1. Read with a **version** (hash or mtime) and require it back on write — an operator editing on
|
||||||
|
disk at the same time gets a conflict rather than a silent overwrite.
|
||||||
|
2. Validate the edited document parses.
|
||||||
|
3. **Back up the current file**, write, reload.
|
||||||
|
4. **Watch for `OnPluginLoaded` within a window.** If it does not arrive, **restore the backup and
|
||||||
|
reload again, automatically**, and report the failure with whatever Oxide logged.
|
||||||
|
|
||||||
|
That rollback is the feature's real content. Without it this is a web form that can take the shard's
|
||||||
|
plugins down one typo at a time.
|
||||||
|
|
||||||
|
**Two more obligations.**
|
||||||
|
|
||||||
|
**Redact secrets.** Plugin configs routinely hold API keys and Discord webhooks. A config reader
|
||||||
|
hands those to anyone who can open the page. Mask values whose key matches the usual shapes — key,
|
||||||
|
token, secret, password, webhook — and treat them **write-only**, exactly as the platform already
|
||||||
|
treats the uo-link token. This is a real leak vector and the generated-form approach walks straight
|
||||||
|
into it.
|
||||||
|
|
||||||
|
**Gate it on its own site permission and audit every write** — who changed which key, from what to
|
||||||
|
what, and whether the reload succeeded. It is an admin writing to the game host's filesystem, which
|
||||||
|
is the most powerful thing the site can do to a server.
|
||||||
|
|
||||||
|
#### One distinction to keep
|
||||||
|
|
||||||
|
**Editing a plugin's config file is not a lease.** A lease (§9) borrows a *convar* for a while and
|
||||||
|
the game restores it on a deadline. This writes a *file* and is permanent until someone changes it
|
||||||
|
back. They look similar from a web form and they are not the same mechanism — an event should never
|
||||||
|
reach for this one.
|
||||||
|
|
||||||
### R17 — ZoneManager joins the required base set, because events need to know where people are
|
### R17 — ZoneManager joins the required base set, because events need to know where people are
|
||||||
|
|
||||||
**Decided 2026-09-15 (org lead).** **[ZoneManager](https://umod.org/plugins/zone-manager)** (k1lly0u,
|
**Decided 2026-09-15 (org lead).** **[ZoneManager](https://umod.org/plugins/zone-manager)** (k1lly0u,
|
||||||
@@ -544,8 +626,8 @@ re-checks it.
|
|||||||
honest reading is that the first list was a game-bridge plan with a website module bolted on, where
|
honest reading is that the first list was a game-bridge plan with a website module bolted on, where
|
||||||
the kit treats the module as the bulk of the work.
|
the kit treats the module as the bulk of the work.
|
||||||
|
|
||||||
0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–7 are
|
0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–7b are
|
||||||
the permissions product. 9 is Teams. 10 is the notifications set, whose catalogue is **§10**. 12–13
|
the permissions and remote-administration product. 9 is Teams. 10 is the notifications set, whose catalogue is **§10**. 12–13
|
||||||
are events, whose catalogue is **§9**. 14 is the map. 18 is how any of it reaches somebody who is
|
are events, whose catalogue is **§9**. 14 is the map. 18 is how any of it reaches somebody who is
|
||||||
not us. The Android legs (5, 8, 11, 15) each trail the
|
not us. The Android legs (5, 8, 11, 15) each trail the
|
||||||
website surface they consume by one phase, per R10.
|
website surface they consume by one phase, per R10.
|
||||||
@@ -562,6 +644,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
|||||||
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
||||||
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
||||||
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe |
|
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe |
|
||||||
|
| 7b | **Mod configuration from the site** (R18). Generated form from the live config values, raw-JSON advanced tier, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** on a failed load, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why |
|
||||||
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
||||||
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
|
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
|
||||||
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
|
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
|
||||||
|
|||||||
Reference in New Issue
Block a user