Merge pull request 'docs(modules): phase 7 as built — the permission mirror, and the set arithmetic behind it' (#259) from docs/rust-phase-7 into main
Reviewed-on: #259
This commit is contained in:
@@ -984,7 +984,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
|||||||
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
|
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
|
||||||
| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged |
|
| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | 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). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 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). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 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. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
||||||
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — 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 |
|
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — 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 |
|
||||||
@@ -2885,6 +2885,189 @@ with no game behind it, one address with nothing listening) and three logins:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 20. Phase 7 as built — site-owned permissions, 2026-09-21
|
||||||
|
|
||||||
|
**The site's half is built and walked; the in-game half is written down as a walk to run.** R2 is
|
||||||
|
the direction the Integration Kit has no chapter for (§2, R2), and building it once is what phase 19
|
||||||
|
was told to wait for. It is also the first phase where this module writes to a game.
|
||||||
|
|
||||||
|
Five repositories touched: `Rust-Plugins#4`, `Rust-Link#4`, `Module-Rust#8` (all into `edge`), this
|
||||||
|
document, and nothing in `website` — core needed no change, which is itself worth recording after
|
||||||
|
phases 5 and 6 both found something missing in the contract.
|
||||||
|
|
||||||
|
### 20.0 The seven decisions this phase needed
|
||||||
|
|
||||||
|
| | Decision |
|
||||||
|
|---|---|
|
||||||
|
| **D28** | **A grant is keyed to the website USER**, resolved to every Steam id they have linked at push time. Not to a Steam id: the site authors privilege for a *person*, phase 13's earned entitlements follow whoever earned them, and unlinking an account takes its privileges with it. The cost, which is real and appears again in §20.5, is that a person with two linked accounts holds it on both |
|
||||||
|
| **D29** | **Every authored row carries a scope** — one server id, or `*` for the fleet. A modded server and a vanilla one will not want one set on both, and a single-server community never sees the choice |
|
||||||
|
| **D30** | **Groups are mirrored as real groups**, not flattened into per-player grants. Third-party plugins read group membership, R15's BetterChat group API (phase 17) has something to hang on, and an operator reading `oxide.show groups` sees what the website shows. The price is §12.2 rule 4: a player the store has never seen cannot be put in a group, while a direct grant to the same account works immediately |
|
||||||
|
| **D31** | **A holder the site did not author is reported, never undone**, and an operator is offered two answers: adopt it (the site maintains it from then on) or revoke it (removed on the next sync). A console grant during an incident must survive the next reconcile |
|
||||||
|
| **D32** | **One verb, and the plugin does the diffing.** The site sends the whole desired set; the plugin compares it against the live store and writes only the difference. Shipping the store to the website was rejected: it is the bigger of the two sets, and a copy of it is a second source of truth that is stale the moment it lands |
|
||||||
|
| **D33** | **Never self-register a permission.** The form offers what the servers report; a name that stops resolving is reported unresolved and the grant is kept. §12.2 rule 3 says `RegisterPermission` would make it stick with a console warning — doing so fabricates a permission the operator never installed, inert until some plugin happens to check it |
|
||||||
|
| **D34** | **People and groups, by hand.** Rules that keep themselves true — "everyone in the Donators team is in `vip`" — are genuinely wanted and are a second authority over the same rows, with its own reconcile. Deferred |
|
||||||
|
|
||||||
|
### 20.1 Three sets, and every interesting question is a difference between two
|
||||||
|
|
||||||
|
```
|
||||||
|
desired − pushed apply
|
||||||
|
pushed − desired RETIRE, because the site put it there and has since withdrawn it
|
||||||
|
present − desired drift
|
||||||
|
```
|
||||||
|
|
||||||
|
The middle row is the whole reason `rust_perm_pushed` exists, and it is the part that cannot be
|
||||||
|
inferred from anything the game knows. **A name in the store that is not in the desired set is
|
||||||
|
either something the site retired or something a human granted**, and those two have opposite
|
||||||
|
correct answers. The store records who granted a permission nowhere, so the site's own memory of
|
||||||
|
what it pushed is the only thing that tells them apart.
|
||||||
|
|
||||||
|
That table is keyed by **Steam id**, not by user, because it records what is in the GAME and the
|
||||||
|
game has never heard of a website account. Unlinking therefore leaves its rows until the next sync
|
||||||
|
retires them, which is correct and would be inexpressible keyed the other way.
|
||||||
|
|
||||||
|
**Revoking drift needed its own table**, and the reason is a good one: a foreign grant often names a
|
||||||
|
Steam id no website account holds, so there is no user to author it against and nothing in
|
||||||
|
`rust_perm_pushed` to remove. `rust_perm_revocations` is an instruction with its own lifetime —
|
||||||
|
queued by a person, carried in the next sync's `retire` list, deleted when a report comes back. A
|
||||||
|
server that is offline keeps the instruction until it returns, which is what an operator expects
|
||||||
|
from a site that claims to be the author of record.
|
||||||
|
|
||||||
|
### 20.2 What lands is not what was sent
|
||||||
|
|
||||||
|
Two outcomes look exactly like success from the website and are not, and both are reported by the
|
||||||
|
plugin rather than assumed:
|
||||||
|
|
||||||
|
- **`unresolved`** — no loaded plugin on that server registered the name, and `GrantUserPermission`
|
||||||
|
no-ops silently for one (§12.2 rule 1). This is the finding with teeth that phase 0 produced, and
|
||||||
|
phase 7 is where it is actually paid for.
|
||||||
|
- **`pending`** — the store has never seen that player, so a group membership cannot be placed
|
||||||
|
(§12.2 rule 4). The plugin reads the membership back after adding it, because a void return and no
|
||||||
|
change is the only signal available.
|
||||||
|
|
||||||
|
**Neither is recorded as pushed.** A site that recorded them would believe it had given a privilege
|
||||||
|
it had not — and would later "retire" it from a server that never had it, a no-op that reads as a
|
||||||
|
success in every log.
|
||||||
|
|
||||||
|
### 20.3 The loop, and what provokes it
|
||||||
|
|
||||||
|
Every 30 seconds it compares a digest of the desired set against what each server last confirmed,
|
||||||
|
and does nothing when they match. A sync happens when the set changed, when the game restarted or
|
||||||
|
wiped (the boot-id and wipe-id watch), when a `perm.drift` hook arrived, when the last attempt
|
||||||
|
failed and its backoff has elapsed, or every 15 minutes regardless — the audit is what finds drift
|
||||||
|
on a server nobody has touched.
|
||||||
|
|
||||||
|
**The digest is sorted before hashing.** The rows come out of five queries in an order nothing
|
||||||
|
guarantees, and an unsorted digest differs between two reads of an unchanged set — which would push
|
||||||
|
to every game server on every tick, for ever.
|
||||||
|
|
||||||
|
`dirty` is an optimisation rather than the truth, and it is written down as such: the loop's real
|
||||||
|
condition is `desired_hash != synced_hash`, recomputed from the tables every tick, so a flag cleared
|
||||||
|
while a sync was in flight costs nothing.
|
||||||
|
|
||||||
|
### 20.4 Nothing the far side sends may cost the main thread unbounded work
|
||||||
|
|
||||||
|
`perm.sync` is the first command whose work is not bounded by its own shape. A community with two
|
||||||
|
thousand linked players sends thousands of store operations in one frame; applying them in the tick
|
||||||
|
they arrive is a freeze an operator will blame on the game.
|
||||||
|
|
||||||
|
The plugin compiles a sync into single-store operations and drains 200 at a time on a timer,
|
||||||
|
reporting when the last one lands. Compiling touches nothing, so an oversized or malformed sync is
|
||||||
|
refused before there is any state to unwind. **This is §5's no-stall rule pointed at the inbound
|
||||||
|
half**, and it is new: every earlier command was a request to repeat something the game already knew.
|
||||||
|
|
||||||
|
The three bounds are each on the side that can say something useful when one is hit — ~15,000 rows
|
||||||
|
at the website (which can name the server to an operator), 1 MiB at the sidecar (the game link's own
|
||||||
|
line cap; forwarded, the line is discarded silently and presents as a `504`), 20,000 operations at
|
||||||
|
the plugin.
|
||||||
|
|
||||||
|
### 20.5 Four defects a browser walk found that 133 green tests did not
|
||||||
|
|
||||||
|
1. **A person with permissions and no linked Steam account had the whole Rust section hidden** on
|
||||||
|
core's user page. The section was gated on `links.length`, phase 6's rule, and phase 7 gave it a
|
||||||
|
second reason to exist — *for exactly the person whose grants reach nobody*, which is the state an
|
||||||
|
operator most needs to see. It is the same class of defect as phase 6's invisible refusal: a
|
||||||
|
correct decision that a later phase quietly invalidated.
|
||||||
|
2. **A member waiting on a first connection looked like an ordinary member.** The count was in the
|
||||||
|
server strip; nothing was beside the person. Fixed by matching each member's accounts against the
|
||||||
|
`pending` entries in every server's report — which also required the overview to carry *all* of a
|
||||||
|
member's Steam accounts rather than whichever one the join returned first.
|
||||||
|
3. **A grant naming an unregistered permission carried no warning**, though a group's permission
|
||||||
|
list had carried one from the start. Same fact, two places, one of them missed.
|
||||||
|
4. The page drew its own `<h1>` under core's chrome title — the same words twice.
|
||||||
|
|
||||||
|
**A fifth was found by a test, and it was the better catch.** `buildDesired` resolved a grant's
|
||||||
|
Steam accounts from the **join** in `listGrants` rather than from the link map, which gives the
|
||||||
|
right answer by accident: the join repeats a grant per linked account. It would have kept giving the
|
||||||
|
right answer until somebody changed that query, at which point one of a person's two accounts
|
||||||
|
quietly stops being granted. The test that caught it was written against a fixture with one row per
|
||||||
|
grant — the fixture was wrong about the query and right about the model.
|
||||||
|
|
||||||
|
### 20.6 What was proven, and how
|
||||||
|
|
||||||
|
A real core at the pinned ref with the module installed, the real `rust-link` sidecar, and a
|
||||||
|
**stand-in plugin** speaking protocol 4 on the loopback — a Node script implementing the store
|
||||||
|
semantics that matter (unregistered names no-op; an unseen player can hold a grant but not a
|
||||||
|
membership; `GetPermissionUsers` answers direct holders only).
|
||||||
|
|
||||||
|
- a group created, its permission added and two memberships applied on the first sync — two, because
|
||||||
|
one member holds two linked Steam accounts (D28 on the wire);
|
||||||
|
- `unresolved: ["kits.gold"]` and `pending: ["7656003:vip"]` reported, and **absent from
|
||||||
|
`rust_perm_pushed`**;
|
||||||
|
- a hand edit found as drift, adopted, and the refusal for one whose Steam id belongs to nobody:
|
||||||
|
*"That Steam account is not linked to any account on this site, so there is nobody to author this
|
||||||
|
against."*;
|
||||||
|
- a drift row revoked — queued, carried on the next sync (`revokes: 1`), the queue row cleared;
|
||||||
|
- a withdrawn grant retired (`pushed − desired`), and its pushed row removed;
|
||||||
|
- **a restart that emptied the store**: `reason: "restart"`, the whole set re-pushed, group,
|
||||||
|
permission, memberships and grant all back. That is R2's central promise, walked;
|
||||||
|
- a `perm.drift` frame marking the server dirty through the ingest, and the next sync reporting the
|
||||||
|
hand edit authoritatively — the live signal and the authoritative answer, in the division §10.4
|
||||||
|
describes;
|
||||||
|
- an unreachable second server reporting `transport-error` and retrying on its backoff while the
|
||||||
|
first stayed in sync;
|
||||||
|
- the module's chunk alone in the browser console: no second React, no bare import.
|
||||||
|
|
||||||
|
### 20.7 What is NOT proven, and who it is waiting on
|
||||||
|
|
||||||
|
**The acceptance line — "a grant made on the website gates a third-party plugin in-game" — is not
|
||||||
|
met**, and the reason was known before the phase started (§12.5): no console session can observe a
|
||||||
|
permission gate, and an admin account bypasses PopupNotifications and ZoneManager unconditionally.
|
||||||
|
It needs a **second, non-admin Steam account** on the rig, which is the org lead's to arrange. The
|
||||||
|
phase was built on the understanding that the in-game leg becomes a walk to run (as D27 did for
|
||||||
|
identity), not a claim.
|
||||||
|
|
||||||
|
**Nothing in the plugin has been compiled.** Two specifics for whoever runs that walk:
|
||||||
|
|
||||||
|
- `GetPermissionUsers` and `GetUsersInGroup` answer with `id(name)` and the spacing differs between
|
||||||
|
the two calls and between the frameworks. The plugin takes everything before the first bracket;
|
||||||
|
confirm it on both rigs, because a parse that is wrong here reports every holder as foreign.
|
||||||
|
- `GetGroupPermissions(name, false)` is called with both arguments. If Carbon's signature has no
|
||||||
|
second parameter this does not compile there — the one place in this change where R19's
|
||||||
|
"byte-identical plugin" claim is at risk.
|
||||||
|
|
||||||
|
### 20.8 Smaller things worth keeping
|
||||||
|
|
||||||
|
- **A mount prefix is ONE path segment.** Core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`, so
|
||||||
|
`/rust/permissions` cannot be declared in `module.json` and has to be a nested `use()` under
|
||||||
|
`/rust`. The useful half: **swagger-autogen follows the `require` and generates it with the right
|
||||||
|
prefix anyway** — the exact opposite of phase 6's hole, where the registration walk could not see
|
||||||
|
a slot router. The generator sees a nested router the walk cannot.
|
||||||
|
- **This module's first admin page**, and the first thing in it that had to be *authored* rather
|
||||||
|
than configured. The server rows are still configured through the API and have no screen —
|
||||||
|
a gap this phase deliberately did not widen into.
|
||||||
|
- **There is no module-declared site permission at MODULE_API 1.10.0**, so `requireRole('admin')` on
|
||||||
|
every route is the whole of the available vocabulary. R18's "its own site permission" for the
|
||||||
|
config editor needs a contract member that does not exist; phase 7b will meet the same wall.
|
||||||
|
- **Adopting a hand grant widens it.** It becomes a grant against the *person*, so it reaches every
|
||||||
|
Steam account they hold — one consequence of D28 that only shows up when adopting, and the next
|
||||||
|
sync applies it to their other accounts. Correct, and worth saying out loud on the screen one day.
|
||||||
|
- The rig had a leftover core **and** a leftover sidecar from phase 6 still running, and the sidecar
|
||||||
|
held the release binary open so `cargo build` failed with `Access is denied (os error 5)` and
|
||||||
|
**exit code 0**. Two sessions' rigs on one machine is now a standing hazard; check ports 3200,
|
||||||
|
8090 and 7799 before starting anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||||
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
|
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
|
||||||
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust
|
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust
|
||||||
|
|||||||
@@ -235,6 +235,31 @@ Two things an operator should know about it:
|
|||||||
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
||||||
the admin panel.
|
the admin panel.
|
||||||
|
|
||||||
|
**From protocol 4 the website owns your permissions.** Groups and grants are written in
|
||||||
|
Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every
|
||||||
|
plugin you already run honours them — Kits, ZoneManager, anything that calls `UserHasPermission`.
|
||||||
|
Nothing is required of those plugins and nothing is configured twice.
|
||||||
|
|
||||||
|
Four things an operator should know about it, because each looks like something else from the game
|
||||||
|
side:
|
||||||
|
|
||||||
|
- **A wipe does not lose them.** The site re-pushes the whole set when the server comes back. If your
|
||||||
|
wipe script clears `oxide/data/`, the permissions the site authored are back within a minute of the
|
||||||
|
server being up; ones granted at the console are not, because nothing remembers those.
|
||||||
|
- **Granting at the console still works, and the website notices.** A hand edit is reported as
|
||||||
|
drift on that screen and is **never** undone on its own — an operator is offered two answers to
|
||||||
|
it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console
|
||||||
|
grant during an incident must survive the next sync.
|
||||||
|
- **A permission no loaded plugin has registered cannot be granted.** Oxide's own API silently does
|
||||||
|
nothing for an unknown name, so the site checks first and reports the name as unresolved instead
|
||||||
|
of claiming a privilege nobody has. Load the plugin and the grant lands by itself.
|
||||||
|
- **A player who has never connected to that server can hold a grant but cannot be in a group.**
|
||||||
|
The store has no record of them to put in a group yet; the site says which memberships are waiting
|
||||||
|
and they land on that player's first connection.
|
||||||
|
|
||||||
|
`rg.perms` at the server console prints what the last sync did, which is the fastest way to tell
|
||||||
|
"that permission does not exist here" from "that player has never been seen here".
|
||||||
|
|
||||||
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
|
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
|
||||||
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
|
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
|
||||||
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||||
|
|||||||
@@ -127,3 +127,50 @@ unreachable right now"* instead, which is the same rule with nothing left to be
|
|||||||
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
||||||
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
||||||
account at a time, and every refusal is a sentence that tells the player what to do next.
|
account at a time, and every refusal is a sentence that tells the player what to do next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The permission walk (protocol 4, phase 7)
|
||||||
|
|
||||||
|
Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring
|
||||||
|
screen, the report, drift and its two answers, and a restart that emptied the store and was fully
|
||||||
|
re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a
|
||||||
|
third-party plugin in the game.**
|
||||||
|
|
||||||
|
It cannot be walked from a console, and it cannot be walked on the owner's account:
|
||||||
|
|
||||||
|
- **A console session bypasses every gate.** The standard idiom is
|
||||||
|
`return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer` —
|
||||||
|
so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)).
|
||||||
|
- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications
|
||||||
|
(`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the
|
||||||
|
exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions`
|
||||||
|
defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner.
|
||||||
|
|
||||||
|
So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be
|
||||||
|
walked on the owner's account; steps 4 and 5 cannot.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen |
|
||||||
|
| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase |
|
||||||
|
| 3 | **At the server console, `oxide.show user <steamid>`** | The permission is there, granted by this plugin rather than by hand |
|
||||||
|
| 4 | **At the console, `oxide.grant user <steamid> zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own |
|
||||||
|
| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove |
|
||||||
|
| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed |
|
||||||
|
| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately |
|
||||||
|
|
||||||
|
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
|
||||||
|
this phase has two specific things to confirm there rather than assume:
|
||||||
|
|
||||||
|
- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing
|
||||||
|
differs between the calls and between the frameworks. The plugin takes everything before the first
|
||||||
|
bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible
|
||||||
|
immediately: the drift list fills with grants the site itself made.
|
||||||
|
- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no
|
||||||
|
second parameter, the plugin does not compile there at all — the one place in protocol 4 where
|
||||||
|
R19's byte-identical-plugin claim is at risk.
|
||||||
|
|
||||||
|
**What counts as a pass:** a non-admin player's access in game changes because of something typed on
|
||||||
|
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
|
||||||
|
operator nothing.
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ it is listening without one.
|
|||||||
|
|
||||||
## 2. Versioning
|
## 2. Versioning
|
||||||
|
|
||||||
The wire version is a single integer — **3** as of identity (§9) — declared in **four** places that
|
The wire version is a single integer — **4** as of the permission mirror (§10) — declared in
|
||||||
must agree:
|
**four** places that must agree:
|
||||||
|
|
||||||
| Where | Repo |
|
| Where | Repo |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -330,11 +330,12 @@ writing the file and generating the token if they are missing — and prints it
|
|||||||
|
|
||||||
## 7. What is deliberately not here yet
|
## 7. What is deliberately not here yet
|
||||||
|
|
||||||
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
|
Protocol 4 is the transport, the read path, identity and the permission mirror. Every one of these
|
||||||
it, and each is a version bump:
|
arrives with the phase that needs it, and each is a version bump:
|
||||||
|
|
||||||
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
||||||
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
|
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||||||
|
- plugin configuration edited from the site (phase 7b)
|
||||||
- clans, for core's Team provider (phase 9)
|
- clans, for core's Team provider (phase 9)
|
||||||
- leases, budgets and the event actions (phases 12-13)
|
- leases, budgets and the event actions (phases 12-13)
|
||||||
- the map image over the asset-bridge shape (phase 14)
|
- the map image over the asset-bridge shape (phase 14)
|
||||||
@@ -702,3 +703,182 @@ is wrong — so they fetch another code from the same server and hear it again.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 10. Protocol 4 — the permission mirror
|
||||||
|
|
||||||
|
R2, and the first command on this bridge that **changes the game**. Protocol 3's
|
||||||
|
`link.confirm` was the website originating a message, but it spent a code the game
|
||||||
|
itself had minted; this writes to a store the game enforces.
|
||||||
|
|
||||||
|
```
|
||||||
|
website sidecar plugin
|
||||||
|
│ │ │
|
||||||
|
├── POST /permissions/sync ►│ ──── perm.sync ─────────►│ diff against the
|
||||||
|
│ the whole desired set │ (the same object) │ live store, apply
|
||||||
|
│ │ │ the difference in
|
||||||
|
│◄──── the report ──────────│◄──── perm.report ────────┤ bounded steps
|
||||||
|
│ │
|
||||||
|
│◄──── perm.drift (event) ──────────────────────────────┤ somebody else wrote
|
||||||
|
```
|
||||||
|
|
||||||
|
**The website is the author of record and the framework's store is an enforcement
|
||||||
|
cache.** Every third-party plugin honours a site grant with no adapter, because
|
||||||
|
they all already call `permission.UserHasPermission` — reaching them is the point,
|
||||||
|
and it is why the site does not keep a private table of its own.
|
||||||
|
|
||||||
|
### 10.1 One verb, and the PLUGIN does the diffing
|
||||||
|
|
||||||
|
`perm.sync` carries the whole set the site authors **for that server**. The plugin
|
||||||
|
compares it against the live store and writes only what differs.
|
||||||
|
|
||||||
|
The alternative — the plugin reporting its store and the website computing the
|
||||||
|
difference — was rejected for two reasons. The store is the bigger of the two sets
|
||||||
|
and would cross the wire constantly, and a website holding a copy of it has a
|
||||||
|
second source of truth that is stale the moment it lands.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cmd": "perm.sync",
|
||||||
|
"reqId": "r-42",
|
||||||
|
"setId": "69dfc769…",
|
||||||
|
"groups": [
|
||||||
|
{ "name": "vip", "title": "VIP", "rank": 10,
|
||||||
|
"permissions": ["kits.vip"],
|
||||||
|
"members": ["76561198000000001", "76561198000000002"] }
|
||||||
|
],
|
||||||
|
"grants": [
|
||||||
|
{ "steamId": "76561198000000001", "permissions": ["kits.gold"] }
|
||||||
|
],
|
||||||
|
"managed": ["kits.vip", "kits.gold"],
|
||||||
|
"retire": [
|
||||||
|
{ "kind": "grant", "subject": "76561198000000003", "object": "kits.silver" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Means |
|
||||||
|
|---|---|
|
||||||
|
| `setId` | the site's digest of the set, echoed in the report. It is how the site knows a report describes the set it sent rather than an earlier one |
|
||||||
|
| `groups` | group definitions, what each carries, and who is in it. **Three separate facts**, because the game can fail at each independently |
|
||||||
|
| `grants` | permissions held by one account without a group |
|
||||||
|
| `managed` | the permission namespace the site claims. Foreign holders are only looked for within it — which also bounds the scan by the site's own set rather than by the size of the store |
|
||||||
|
| `retire` | what the site put there and has since withdrawn (§10.3) |
|
||||||
|
|
||||||
|
### 10.2 `perm.report` — what actually happened
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "perm.report", "type": "reply", "reqId": "r-42", "setId": "69dfc769…",
|
||||||
|
"applied": { "grants": 1, "revokes": 0, "groupsCreated": 1, "groupPermissions": 1,
|
||||||
|
"members": 2, "membersRemoved": 0, "groupsRemoved": 0,
|
||||||
|
"groupPermissionsRemoved": 0 },
|
||||||
|
"alreadyCorrect": 14,
|
||||||
|
"absent": 0,
|
||||||
|
"unresolved": ["kits.gold"],
|
||||||
|
"pending": ["76561198000000003:vip"],
|
||||||
|
"foreign": [{ "kind": "grant", "subject": "76561198000000009", "object": "kits.admin" }],
|
||||||
|
"operations": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`unresolved` and `pending` are the two ways a push looks like it worked and did
|
||||||
|
not**, and both are load-bearing:
|
||||||
|
|
||||||
|
- **`unresolved`** — no loaded plugin on that server has registered the name.
|
||||||
|
`permission.GrantUserPermission` returns void, throws nothing and logs nothing
|
||||||
|
for an unregistered name ([PLAN.md §12.2](../modules/rust/PLAN.md) rule 1), so
|
||||||
|
without the `PermissionExists` pre-check the grant vanishes without a trace. The
|
||||||
|
plugin does **not** register the name itself: that fabricates a permission the
|
||||||
|
operator never installed.
|
||||||
|
- **`pending`** — the store has never seen that player, so there is no user record
|
||||||
|
to put in a group (§12.2 rule 4). A **direct grant** to the same account works
|
||||||
|
immediately, and the asymmetry is exactly why groups are not the only shape the
|
||||||
|
site can express. The membership lands on their first connection.
|
||||||
|
|
||||||
|
Neither is recorded by the website as pushed. A site that recorded them would
|
||||||
|
believe it had given a privilege it had not — and would later "retire" it from a
|
||||||
|
server that never had it, which is a no-op that reads as a success in every log.
|
||||||
|
|
||||||
|
**A refusal of the whole sync is `perm.error`**, with a reason of `busy` (an
|
||||||
|
earlier sync is still draining) or `too-large`. Like `link.error` it is a `200`
|
||||||
|
from the sidecar: the transport worked and the game answered.
|
||||||
|
|
||||||
|
### 10.3 Retirement is the one thing the game cannot work out
|
||||||
|
|
||||||
|
A name in the store that is not in the desired set is **either** something the site
|
||||||
|
authored and has since withdrawn **or** something a human granted at a console —
|
||||||
|
and those two have opposite correct answers. The store records who granted a
|
||||||
|
permission nowhere, so only the website can tell them apart, from its own memory of
|
||||||
|
what it pushed.
|
||||||
|
|
||||||
|
So the site sends `retire` explicitly, and everything else it did not ask for comes
|
||||||
|
back as `foreign`. **Nothing in `foreign` is ever removed by a sync** (D31): a
|
||||||
|
console `oxide.grant` during an incident is drift, not an error, and an operator is
|
||||||
|
offered two answers to it on the website — adopt it, or revoke it.
|
||||||
|
|
||||||
|
### 10.4 `perm.drift` — a reason to reconcile, not the reconciliation
|
||||||
|
|
||||||
|
Both frameworks raise a hook for every permission write. The plugin subscribes to
|
||||||
|
six of them and emits `perm.drift` for writes **it did not make itself**, staff
|
||||||
|
class (§8.5): it names a Steam id beside a privilege, which is a fact about a
|
||||||
|
person's standing rather than about what happened on the server.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "kind": "perm.drift", "type": "event", "action": "granted",
|
||||||
|
"steamId": "76561198000000009", "permission": "kits.admin" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` is one of `granted`, `revoked`, `group-added`, `group-removed`,
|
||||||
|
`group-permission-granted`, `group-permission-revoked`.
|
||||||
|
|
||||||
|
**It cannot say whether the change is foreign** — only the desired set can, and
|
||||||
|
that comparison happens in a sync. So the website treats the frame as a reason to
|
||||||
|
reconcile *soon*: a hand edit shows up in seconds instead of at the next audit, and
|
||||||
|
the authoritative answer still arrives as a report. That division is what makes the
|
||||||
|
hooks safe to trust at this weight: one that stops firing on a framework upgrade
|
||||||
|
costs latency, not correctness.
|
||||||
|
|
||||||
|
The plugin suppresses them while it is applying a sync, because they fire for its
|
||||||
|
own writes too — and the site cannot tell its own grant from a human's by looking
|
||||||
|
at one.
|
||||||
|
|
||||||
|
### 10.5 Nothing the far side sends may cost the main thread unbounded work
|
||||||
|
|
||||||
|
This is the first command whose work is **not** bounded by its own shape. A
|
||||||
|
community with two thousand linked players sends thousands of store operations in
|
||||||
|
one frame, and applying them in the tick the frame arrives is a freeze an operator
|
||||||
|
will blame on the game.
|
||||||
|
|
||||||
|
So a sync is compiled into a list of single-store operations and drained a few
|
||||||
|
hundred at a time on a timer; the report goes back when the last one lands.
|
||||||
|
Compiling touches nothing, so an oversized or malformed sync is refused before any
|
||||||
|
state exists to unwind. That is §5's rule — the one that keeps a wedged sidecar
|
||||||
|
from stalling the game — pointed at the inbound half.
|
||||||
|
|
||||||
|
Three bounds, each on the side that can say something useful when it is hit:
|
||||||
|
|
||||||
|
| Bound | Where | Why there |
|
||||||
|
|---|---|---|
|
||||||
|
| ~15,000 rows | the website | it can name the server and reach an operator |
|
||||||
|
| 1 MiB | the sidecar | it is the game link's own line cap (§3.1); forwarded, the line is discarded silently and presents as a `504` |
|
||||||
|
| 20,000 operations | the plugin | past it, a half-applied permission set is the state nobody can reason about |
|
||||||
|
|
||||||
|
### 10.6 `GET /permissions/catalogue`
|
||||||
|
|
||||||
|
A live round trip to the plugin: every permission the loaded plugins have
|
||||||
|
registered, and the groups the store holds. It is the option source behind the
|
||||||
|
website's authoring form — a grant can only be written against a name that will
|
||||||
|
actually resolve — and, like `/status`, it fails when the game is down, because
|
||||||
|
"what exists right now" has no stale answer worth giving.
|
||||||
|
|
||||||
|
### 10.7 What the sidecar does NOT do
|
||||||
|
|
||||||
|
It defines no schema for either body. Protocol 4 adds the largest command on this
|
||||||
|
bridge and touches neither the store nor the feed, which is §8.1's dumb-forwarder
|
||||||
|
property paying for itself a second time.
|
||||||
|
|
||||||
|
What it does own is the envelope: `cmd` and `reqId` are written over whatever the
|
||||||
|
caller sent, so no request can arrive claiming to be a different command or aimed
|
||||||
|
at a correlation id somebody else is waiting on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user