docs(modules): R18 discovery is a recursive walk, and it stops at oxide/config

Configuration is not one flat oxide/config/<Plugin>.json per plugin. Plugins
nest - oxide/config/<Mod>/whatever.json and deeper - and one plugin may own
several files. So discovery is a recursive walk and the UI groups by plugin
rather than assuming one file each. Four things follow, and the first is a
boundary rather than a detail.

oxide/data/ is NOT the settings surface and must not be walked into.
DataFileSystem writes there and that is live state, not configuration. The base
set makes the point by itself: Kits keeps Kits/kits_data.json and
Kits/player_data.json, ZoneManager keeps ZoneManager/zone_data.json, and Clans
keeps clan_data.json with a legacy clans_data.json beside it - which is also a
reminder that these names are not stable. Editing those from a web form edits
players' kit cooldowns and the live zone definitions, a running plugin
overwrites the change on its next save, and oxide.reload does not make most
plugins safely re-read them. Different problem, different answer, deliberately
out of scope.

The reload target cannot be inferred from the path. oxide/config/Foo/bar.json
may belong to plugin Foo or to something else; the folder name is convention,
not contract. So the target is an explicit field with the folder name as its
default guess. Infer it silently and the failure is the nastiest kind available
here: we reload the wrong plugin, observe OnPluginLoaded for IT, and report
success while the plugin that was actually edited never re-read anything.

A relative path from a web form is a path-traversal surface. Canonicalise the
resolved path, assert it is under the config root, reject absolute paths, reject
symlinks resolving outside. Before this amendment the feature addressed files by
plugin name; addressing them by path is exactly the change that introduces the
bug class.

And bound it: depth limit, file-count limit, per-file size cap - a pathological
tree must not be enumerated and a multi-megabyte JSON must not be loaded into a
form. Because one plugin can own several files, the backup and rollback operate
on the whole set a save touches rather than one file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 12:34:38 -05:00
parent 82f2b0d18d
commit 6392f39512

View File

@@ -385,7 +385,7 @@ under a prefix. A noun from our own domain that equals the module id cannot coll
**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
- **Base:** the site walks `oxide/config/` **recursively** 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.
@@ -400,6 +400,43 @@ on demand** ([kit][kit] ch. 3 §2a). Do not build it on the permission mirror.
`OnPluginUnloaded` are real hooks** in the Server category, so *whether a reload actually succeeded
is observable* rather than assumed.
#### Discovery is a recursive walk, and it stops at `oxide/config/`
**Amended 2026-09-15 (org lead).** Configuration is **not** one flat `oxide/config/<Plugin>.json` per
plugin. Plugins nest — `oxide/config/<Mod>/whatever.json`, and deeper — and one plugin may own
several files. So discovery is a **recursive walk** of the config tree, and the UI groups by plugin
rather than assuming one file each.
Four things follow, and the first is a boundary rather than a detail.
**`oxide/data/` is not the settings surface, and must not be walked into.** `DataFileSystem` writes
to `oxide/data/`, and that is **live state, not configuration**. The base set makes the point by
itself: Kits keeps `Kits/kits_data.json` and `Kits/player_data.json`, ZoneManager keeps
`ZoneManager/zone_data.json`, Clans keeps `clan_data.json` (and a legacy `clans_data.json` beside it,
which is also a reminder that these names are not stable). Editing those from a web form edits
**players' kit cooldowns and the live zone definitions**, a running plugin overwrites the change on
its next save, and `oxide.reload` does not make most plugins safely re-read them. It is a different
problem with a different answer and it is deliberately out of scope. If a plugin's *settings* genuinely
live under `data/`, that is a per-plugin exception someone opts into knowingly, never something the
walk discovers on its own.
**The reload target cannot be inferred from the path.** `oxide/config/Foo/bar.json` may belong to
plugin `Foo`, or to something else entirely — the folder name is convention, not contract. So the
reload target is an **explicit field with the folder name as its default guess**, confirmable by the
admin. Infer it silently and the failure is the nastiest kind available here: we reload the wrong
plugin, observe `OnPluginLoaded` for *it*, and report success while the plugin that was actually
edited never re-read anything.
**A relative path from a web form is now a path-traversal surface.** Canonicalise the resolved path,
assert it is genuinely under the config root, reject absolute paths, and reject symlinks that resolve
outside. Before this amendment the feature addressed files by plugin name; now it addresses them by
path, and that is exactly the change that introduces the bug class.
**Bound the walk and the file.** A depth limit, a file-count limit and a per-file size cap — a
pathological tree must not be enumerated and a multi-megabyte JSON must not be loaded into a form.
And because one plugin can own several files, **the backup and rollback operate on the whole set a
save touches**, not one file at a time.
#### The trap that would silently corrupt every float
**JavaScript cannot tell `1` from `1.0`, and Oxide configs deserialize into typed C# classes.**
@@ -644,7 +681,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 |
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `oxide/config/` (never `oxide/data/`), generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, 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; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 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 |