docs(modules): R18 edits plugin configuration from the site, with a generated form and an auto-reload
An admin edits any loaded plugin's configuration from the website and it reloads
automatically. Base tier generates a form from the config VALUES themselves -
boolean to toggle, number to numeric field, string to text, array to list,
nested object to group - so it works for whatever plugins happen to be
installed, including ones added after we ship. Advanced tier is raw JSON.
Same posture as R2, the site as authority over the game host, but a different
SHAPE: R2 is continuously reconciled state pushed on connect, this is
request/reply on demand. It must not be built on the permission mirror.
The mechanics, all verified: configs at oxide/config/<Plugin>.json, oxide.reload
rereads one, and OnPluginLoaded / OnPluginUnloaded are real hooks in the Server
category - so whether a reload actually SUCCEEDED is observable rather than
assumed. That is what makes the feature safe.
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 of
{"Rate":1.0} yields the number 1 and JSON.stringify writes it back as 1, so a
naive read-modify-write 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 textually, 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 are recorded, since the
feature's whole promise is that it works without knowing the plugin: empty
arrays and null carry no type; enum-like strings are indistinguishable from free
text; there are no descriptions, minimums or maximums, so the key name is the
entire label; nested objects need recursion with a depth limit and a raw-JSON
fallback; and the file after a reload may not be what we wrote, because Oxide
merges missing defaults and saves.
Safety needs more than usual here, because a bad config does not fail the write,
it fails the next LOAD and the plugin stays down - and R6/R17 make four plugins
required, so a broken ZoneManager config takes event participation with it. The
write path is: read with a version and require it back on write so a concurrent
on-disk edit conflicts rather than being clobbered; validate it parses; back up,
write, reload; then watch for OnPluginLoaded within a window and, if it does not
arrive, restore the backup and reload again AUTOMATICALLY. 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. Plugin configs routinely hold API keys and Discord
webhooks, so a config reader hands those to anyone who can open the page: mask
values whose key matches key/token/secret/password/webhook and treat them
write-only, as the platform already treats the uo-link token. And gate it on its
own site permission with an audit trail of who changed which key from what to
what and whether the reload succeeded - it is an admin writing to the game
host's filesystem, the most powerful thing the site can do to a server.
One distinction kept explicit: editing a config FILE is not a lease. A lease
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 an event should never reach for this one.
Lands as phase 7b, beside permissions, since it shares the admin surface and the
gating.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# `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
|
||||
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
|
||||
`/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
|
||||
|
||||
**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
|
||||
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
|
||||
the permissions product. 9 is Teams. 10 is the notifications set, whose catalogue is **§10**. 12–13
|
||||
0–4 produce a working read-only multi-server Rust site that an operator can actually install. 6–7b are
|
||||
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
|
||||
not us. The Android legs (5, 8, 11, 15) each trail the
|
||||
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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user