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:
@@ -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
|
**Decided 2026-09-15 (org lead).** An admin edits any loaded plugin's configuration from the website
|
||||||
and it reloads automatically. Two tiers:
|
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
|
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
|
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.
|
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
|
`OnPluginUnloaded` are real hooks** in the Server category, so *whether a reload actually succeeded
|
||||||
is observable* rather than assumed.
|
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
|
#### The trap that would silently corrupt every float
|
||||||
|
|
||||||
**JavaScript cannot tell `1` from `1.0`, and Oxide configs deserialize into typed C# classes.**
|
**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 |
|
| 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 |
|
| 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 |
|
| 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