Compare commits
24 Commits
2f24236993
...
docs/rust-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f18097223 | |||
| 46b565ac9b | |||
| 4049abc41a | |||
| f1b8329540 | |||
| 83e57e6f47 | |||
| b7d9872fed | |||
| cb8e8853f6 | |||
| aa148adfc6 | |||
| edff0e3695 | |||
| eea4f958b2 | |||
| de25ed3276 | |||
| a4c4476c3e | |||
| 3b7b9cca4f | |||
| eb4a8bcafc | |||
| 11b8965aa7 | |||
| 15d64b28fe | |||
| 7f008fd1f3 | |||
| 82145d3b4a | |||
| 132205f0f4 | |||
| bba2ab04e0 | |||
| 4fe8864939 | |||
| e35880e713 | |||
| 54b4059091 | |||
| dfdb0a3f63 |
282
android/PLAN.md
282
android/PLAN.md
@@ -229,7 +229,8 @@ Verified green: `:app:testDebugUnitTest` (18 new JVM tests — notifications DTO
|
||||
parse incl. malformed, topic/URL building, stream→route map + personal gating) + `:app:lintDebug` +
|
||||
`:app:assembleDebug`; backend 250 tests (+3 for `push.ntfyUrl`) and `npm run swagger` clean. The
|
||||
foreground-service tradeoff (§11) and the POST_NOTIFICATIONS runtime permission are implemented as
|
||||
planned; an on-device delivery pass against a live ntfy is the one open QA item.
|
||||
planned; an on-device delivery pass against a live ntfy is the one open QA item. *(Closed by M16 on
|
||||
2026-09-23: a raid alert delivered through `ntfy.sh` to an emulator, 19 seconds end to end.)*
|
||||
|
||||
**Part 2 (original plan) — the Android app.** UnifiedPush receiver + device registration
|
||||
against the merged Part-1 contract, a Notifications settings screen, and notification-tap deep-links.
|
||||
@@ -313,7 +314,10 @@ Work items:
|
||||
`GET …/streams` with a per-stream toggle bound to `GET/PUT …/subscriptions`; a **personal** stream
|
||||
(`requiresLinkedAccount`) is greyed with a "link a game account" hint until the user has a linked
|
||||
account — reuse the linked-accounts signal already fetched for M4's player surface
|
||||
(`PlayerShardRepository`), not a fresh source of truth. Toggling to a non-empty set triggers the
|
||||
(`PlayerShardRepository`), not a fresh source of truth. *(M16 amends both halves: the signal is now
|
||||
asked of the site's own module by capability (`LinkedAccountRepository`), because on a Rust site
|
||||
`PlayerShardRepository` answered no for everyone. It holds back only switching push **on**, and no
|
||||
longer greys the whole row.)* Toggling to a non-empty set triggers the
|
||||
register flow (#5) and requests `POST_NOTIFICATIONS`; emptying the set unregisters. Each mutation
|
||||
folds its `ApiResult` into a section-scoped, localized banner (§7 parity with M4).
|
||||
7. **Deep-links (resolves the §13 open item).** Tapping a notification opens the app to the stream's
|
||||
@@ -324,7 +328,8 @@ Work items:
|
||||
`label`) and deep-links — it does **not** pull `ref` content first; the content-free design means
|
||||
nothing needs decrypting to render the tap, and the target screen fetches fresh over the
|
||||
authenticated API on open. (Pulling `ref` for a richer inline notification is a possible later
|
||||
enhancement, not v1.)
|
||||
enhancement, not v1.) *(M16 did it for `notification:<id>` refs: `PushContentResolver` pulls the
|
||||
inbox row and titles the notification with it. Every other ref keeps the generic title.)*
|
||||
8. **Menu.** Add a **Notifications** entry to the signed-in group in `ui/navigation/Menu.kt` (near My
|
||||
Account), visible once signed in.
|
||||
9. **Permission UX.** Request `POST_NOTIFICATIONS` at the moment the user first enables a stream (API
|
||||
@@ -1307,6 +1312,273 @@ push, and Play (M6–M8) follow the designed app.
|
||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
||||
participation history self-scoped, proved by two accounts rather than asserted.
|
||||
|
||||
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
|
||||
module** reached its first public pages in `module-rust` phase 4
|
||||
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
|
||||
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
|
||||
route here existed and answered before a line of Kotlin was written.
|
||||
|
||||
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
|
||||
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
|
||||
what the app does about it.
|
||||
|
||||
**No backend work beyond one word.** The five public routes were live. The one change is
|
||||
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
|
||||
|
||||
#### Why this is not the shard screens with a different name
|
||||
|
||||
The two games have genuinely different shapes, and collapsing them would have cost the app the
|
||||
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
|
||||
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
|
||||
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
|
||||
which case both trees exist at once and neither row appears on a site without its module.
|
||||
|
||||
| Screen | Route | Reads |
|
||||
| --- | --- | --- |
|
||||
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
|
||||
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
|
||||
|
||||
#### Four decisions, taken by the org lead on 2026-09-16
|
||||
|
||||
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
|
||||
string, `shard`, because that is the only question a capability can answer: *is the module
|
||||
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
|
||||
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
|
||||
single list, so gating on `servers` would let another module declaring that word silently reveal
|
||||
these screens on a site that does not run Rust. Gating on the module **id** was considered and
|
||||
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
|
||||
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
|
||||
same thing would quietly end that separation. So the module declares its own name as a sixth
|
||||
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
|
||||
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
|
||||
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
|
||||
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
|
||||
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
|
||||
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
|
||||
was closed in engagement Phase 8.
|
||||
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
|
||||
|
||||
#### A refresh is not a load, and the app had only ever done loads
|
||||
|
||||
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
|
||||
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
|
||||
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
|
||||
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
|
||||
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
|
||||
|
||||
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
|
||||
failure with rows on screen keeps them and reports the failure beside them; a failure with
|
||||
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
|
||||
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
|
||||
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
|
||||
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
|
||||
|
||||
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
|
||||
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
|
||||
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
|
||||
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
|
||||
|
||||
#### The drawer badge is D15 translated, not D15 copied
|
||||
|
||||
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
|
||||
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
|
||||
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
|
||||
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
|
||||
reader says "42 players online" rather than "42". The count rides there, and keeps the website
|
||||
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
|
||||
read keeps the last number, and it never polls. It is asked for only where the module is
|
||||
installed, so a UO site makes no request at all.
|
||||
|
||||
#### Verified
|
||||
|
||||
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
|
||||
that has never reported.
|
||||
|
||||
**Both halves of the phase criterion, directly.** With its server unreachable and reading
|
||||
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
|
||||
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
|
||||
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
|
||||
|
||||
Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was
|
||||
stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line);
|
||||
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
|
||||
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
|
||||
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
|
||||
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
|
||||
|
||||
#### The walk found three defects, and 644 green tests found none of them
|
||||
|
||||
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
|
||||
alone, so it was read at connect and never again — which is not what *live* means on a row
|
||||
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge.
|
||||
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
|
||||
no padding of its own; each caller pads its own content, and these four did not. On a phone the
|
||||
first glyph of each line read as clipped.
|
||||
- **A name touched its own kill count.** Five numeric columns beside an equal-weight name column
|
||||
left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes,
|
||||
and the **active sort is marked on the header** rather than by tinting a column of numbers — the
|
||||
header is the control, and tinting the values says *these are special* instead of *this is what
|
||||
the table is ordered by*.
|
||||
|
||||
#### The rig note worth keeping
|
||||
|
||||
The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only**
|
||||
— not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||
`adb reverse tcp:<port> tcp:<port>` and the loopback address; typed as `10.0.2.2` every request
|
||||
fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the
|
||||
connect screen reports — correctly, and indistinguishably from a core that is not running.
|
||||
|
||||
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
|
||||
surface. Server configuration, the sidecar token and the connection test are admin
|
||||
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
||||
and C (phases 8 and 11), and the map is leg D.
|
||||
|
||||
16. **M15 — the player's own Rust account** (post-v1; built 2026-09-22). `module-rust` phase 8, and
|
||||
R10's leg B: identity (phase 6) and the half of site-owned permissions (phase 7) a player is
|
||||
allowed to see. Every route existed and answered before a line of Kotlin was written except one,
|
||||
`GET /player/rust/permissions`, which this phase added on the website for exactly this screen.
|
||||
|
||||
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §22 for the phase as
|
||||
built and its three decisions. The contract is normative there; this entry records what the app
|
||||
does about it.
|
||||
|
||||
#### It is `CharactersScreen` for a different game, deliberately
|
||||
|
||||
The org lead's instruction was to mirror `module-uo`, and the mirror is exact: **one drawer row
|
||||
under the player group**, with the code card at the top of the screen it opens — the same shape
|
||||
UO has had since M4, where the `[link` card sits above the character rosters. Two alternatives
|
||||
were rejected for reasons the mirror makes obvious: a tab under one Rust server (a link is
|
||||
**fleet-wide** — one Steam account is one person on every server, while stats are per server and
|
||||
per wipe), and a section inside core's own Account screen (the app has no slot mechanism, so the
|
||||
module's data would be hard-wired into a core screen).
|
||||
|
||||
| Screen | Route | Reads |
|
||||
| --- | --- | --- |
|
||||
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
|
||||
|
||||
#### The gate is `rust`, and `PLAYER` means staff too
|
||||
|
||||
`module-uo`'s five shard rows all hang on `shard`, and this hangs on `rust` for the same reason
|
||||
D16 gave: a capability answers *is the module there*, and core flattens every started module's
|
||||
capabilities into one list, so a surface word like `identity` is not something a row may hang
|
||||
on. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is right here — `/player/rust/*` is
|
||||
`requireAuth` with no role above it, and staff play the game as well.
|
||||
|
||||
#### Two reads, and neither blocks the other
|
||||
|
||||
The accounts and the entitlements load separately and fail separately. That is not tidiness: an
|
||||
entitlement is authored against the **website** account, so it exists before a Steam id does, and
|
||||
the person who has just been given something and has not linked yet is exactly the one who needs
|
||||
to see both halves at once. The screen says so in as many words when nothing is linked.
|
||||
|
||||
**The app does no scope arithmetic.** `*` never reaches a screen: each entry arrives carrying the
|
||||
servers its scope reaches, each already marked *has it* or *waiting*, because a second
|
||||
implementation of that rule is a second thing to keep true.
|
||||
|
||||
#### The refusals stay four pieces of advice
|
||||
|
||||
A refusal is chosen by **status** and rendered from a string resource, the convention every
|
||||
earlier milestone follows (the app is localized; the website's sentence is not). 400 is a spent
|
||||
code, 409 is a Steam account another website account holds — released with `/unlink` in game,
|
||||
never moved silently — 429 is the server's limiter, and 503 is a server that could not be
|
||||
reached. The last of those may **not** say "get a new code": the code is still good, and a player
|
||||
told otherwise goes back to the same unreachable server for another one.
|
||||
|
||||
The known cost, written down rather than discovered later: the website distinguishes three 503s
|
||||
by sentence (one server unreachable, the whole fleet down, no servers configured at all) and the
|
||||
app has one string for the status, written to be true of all three.
|
||||
|
||||
#### Verified
|
||||
|
||||
The app suite (**657 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||
against a core with the module installed and a **live Oxide rig** behind it — the first Rust leg
|
||||
where the backend was talking to a real game server rather than a stand-in.
|
||||
|
||||
Walked directly: the row absent signed-out and absent on a UO site, present for a signed-in
|
||||
player; both reads; a code the live plugin genuinely refused, with its advice rendering **beside
|
||||
the button** rather than at the top of a long form; a rank marked *has it* and a grant marked
|
||||
*waiting* on the same screen, which is what the pushed ledger actually said; and the release.
|
||||
|
||||
#### The walk found one thing the suite did not
|
||||
|
||||
**The row said who, and not when or where.** The website's row has always read "linked just now
|
||||
on rust-oxide"; the app's carried the name and the Steam id and stopped. Neither fact is part of
|
||||
the identity — a link is fleet-wide — but which server minted the code is where a support
|
||||
conversation starts, and the module's own schema says so in a comment.
|
||||
|
||||
- **Excluded**, in the same class as M14's exclusions: the Rust **admin** permission surface.
|
||||
Authoring grants, groups and drift is `requireRole('admin')` on the website, the app has no
|
||||
admin user-detail screen to put it in, and it writes into a running game — the phone is where
|
||||
you read what you hold, not where you decide what somebody else holds.
|
||||
- **No deep link yet.** `/player/rust` is not in the app's web-path table, deliberately:
|
||||
`module-uo`'s player screens are not either, and that table is built from the *public* nav.
|
||||
Phase 10 is when it will matter, because a notification about an entitlement will want
|
||||
somewhere to land.
|
||||
|
||||
#### Amended 2026-09-22 — M14's Online tab and feed stop naming players by default
|
||||
|
||||
Not a milestone of its own: a correction to what M14 shipped, made on the website first
|
||||
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §23). **Nothing names who is online by
|
||||
default** — the module now withholds the Online list, and every feed item that says a named
|
||||
player was on, from anyone below an operator-chosen audience (staff unless widened), while the
|
||||
player **count** stays public.
|
||||
|
||||
The app's part is to never read that as *nobody is on*. `RustOnlineDto` carries `hidden`,
|
||||
`count` and `audience`, and `RustEventListDto` carries `presenceHidden` and `presenceAudience`;
|
||||
the repository and view model keep the whole answer rather than its rows. The Online tab says
|
||||
"2 players online" and who can see the names; the feed says once, above the rows, that joins,
|
||||
deaths and chat are not shown. An older module without the flags decodes as visible, as before.
|
||||
Walked on an emulator: the withheld panel at the default, and the names arriving on the next poll
|
||||
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
|
||||
check. Branch `fix/rust-presence-visibility`.
|
||||
|
||||
17. **M16 — a Rust notification on a phone** (post-v1; built 2026-09-23). `module-rust` phase
|
||||
11, R10's leg C: the app half of phase 10's notifications. **Design of record:
|
||||
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §26**, with its four decisions (D69–D72).
|
||||
|
||||
The inbox (engagement phase 8) and the preferences screen (engagement phase 3) are drawn from the
|
||||
wire, so every Rust trigger already arrives as a row, with no app change. This milestone covers
|
||||
the three places where the app still assumed it was talking to a UO shard:
|
||||
|
||||
- **The link check** behind the *Your game account* section asked `module-uo`'s
|
||||
`/player/shard/accounts`. On a Rust site that locks the raid alert in every channel. It will
|
||||
ask the site's own module, chosen by capability (`rust` → `/player/rust/links`). Switching a
|
||||
channel **off** is never gated, and only switching push **on** needs a link.
|
||||
- **A push is titled from the item it points at.** A `notification:<id>` ref is pulled over the
|
||||
authenticated inbox API, and the relay still carries nothing. The per-stream title stays as the
|
||||
fallback and as the lock-screen version. The M7 items that said nothing is
|
||||
fetched to show a notification are amended in place.
|
||||
- **`/player/rust` and `/rust/servers/<id>?tab=<tab>` open natively.** M15 deferred the first
|
||||
of these to exactly this phase.
|
||||
|
||||
It also closes **M7's open QA item**. On-device delivery against a live ntfy has never been
|
||||
walked. It is walked here, on `ntfy.sh` (D72).
|
||||
|
||||
#### As built (2026-09-23)
|
||||
|
||||
Branch `feat/rust-phase-11-notifications`. The app suite is **672 tests, 0 failures**, up from
|
||||
657, with `lintDebug` and `assembleDebug` green. It was walked on the `s22_ultra` emulator
|
||||
against real core on `main`, with the module installed, the protocol-7 sidecar inside the Oxide
|
||||
rig's container, and the relay on `ntfy.sh`:
|
||||
|
||||
- **Linked (raidowner1):** the raid alert's push switched on, and the device registered
|
||||
`https://ntfy.sh/<topic>`. A door raided with phase 10's rig helper reached the phone
|
||||
**19 seconds** later as *"Your base is being raided — A door was destroyed in P16 on Oxide
|
||||
rig."* The notification was `vis=PRIVATE`, with a public version carrying only the generic
|
||||
title. Tapping it opened the inbox, and the row opened the Oxide rig's screen natively.
|
||||
- **Switched off in the app:** push off, and the next raid enqueued in-app only. The inbox row
|
||||
arrived and no system notification was posted. With nothing else on push, the device was
|
||||
unregistered and the push service stopped.
|
||||
- **Not linked (walkadmin):** push held back with the hint, while email and in-app stayed live.
|
||||
In-app was switched off, stored as `off`, and switched back on.
|
||||
- **D71:** a row linking `?tab=leaderboard` opened on the Leaderboard tab, and `/player/rust`
|
||||
opened *My Rust account*. `?tab=clans` went to a Custom Tab.
|
||||
|
||||
**Not walked:** a UO site (no UO core was running; the path is the call it always was, and a test
|
||||
pins it), and the lock-screen rendering (the emulator has no screen lock; the public version was
|
||||
read back from `dumpsys notification`).
|
||||
|
||||
### Deferred (not a milestone)
|
||||
|
||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||
@@ -1529,7 +1801,9 @@ of push.
|
||||
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
|
||||
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
|
||||
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
|
||||
that a row exists, and the contract stays wake-and-pull.
|
||||
that a row exists, and the contract stays wake-and-pull. *(M16 decodes it once more, to the id,
|
||||
and only to pull that row over the authenticated inbox API and title the notification with it. It is
|
||||
still never rendered.)*
|
||||
|
||||
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
|
||||
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
|
||||
|
||||
1795
modules/rust/PLAN.md
1795
modules/rust/PLAN.md
File diff suppressed because it is too large
Load Diff
135
rust-link/INSTALL_RIG.md
Normal file
135
rust-link/INSTALL_RIG.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# The sidecar inside the game container — the Pterodactyl rig recipe
|
||||
|
||||
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
|
||||
is not a phase: it is how the rigs are wired from now on, and it is the shape
|
||||
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
|
||||
|
||||
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
|
||||
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
|
||||
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
|
||||
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
|
||||
|
||||
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
|
||||
A container's `127.0.0.1` is genuinely private, so a stock plugin config and a stock sidecar find
|
||||
each other with nothing configured at all.
|
||||
|
||||
---
|
||||
|
||||
## What goes on the volume
|
||||
|
||||
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
|
||||
| `with-sidecar.sh` | The launcher below, `chmod 755` |
|
||||
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
|
||||
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
|
||||
|
||||
Building the binary needs no Rust toolchain on the host:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
|
||||
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
|
||||
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
|
||||
```
|
||||
|
||||
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
|
||||
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
|
||||
call:** a two-entry `files` array applied only the first, silently, on this panel.
|
||||
|
||||
---
|
||||
|
||||
## The launcher
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
RL=/home/container/rust-link
|
||||
mkdir -p "$RL"
|
||||
|
||||
export RUSTLINK_CONFIG="$RL/sidecar.toml"
|
||||
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
|
||||
export RUSTLINK_DB_PATH
|
||||
|
||||
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
|
||||
eval "val=\${$v-}"
|
||||
if [ -n "$val" ]; then export "$v"; fi
|
||||
done
|
||||
|
||||
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
|
||||
|
||||
exec "$@"
|
||||
```
|
||||
|
||||
Three things in it are load-bearing, and each is a thing that went wrong first:
|
||||
|
||||
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
|
||||
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
|
||||
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
|
||||
inherits it from this script's environment and still boots modded — which is the composition R20
|
||||
left unsettled, and this is the answer.
|
||||
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
|
||||
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
|
||||
container. A `wait` here instead would leave the panel talking to a shell.
|
||||
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
|
||||
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
|
||||
nothing.
|
||||
|
||||
## The startup command
|
||||
|
||||
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
|
||||
the way egg variables will supply them in phase 18 (R22):
|
||||
|
||||
```
|
||||
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
|
||||
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
|
||||
```
|
||||
|
||||
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
|
||||
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
|
||||
|
||||
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
|
||||
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
|
||||
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
|
||||
that survives that, which is why the launcher takes the game command as arguments rather than
|
||||
containing it.
|
||||
|
||||
## Wiring the website to it
|
||||
|
||||
The sidecar's web API is on the **second allocation**, so from the site it is
|
||||
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
|
||||
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
|
||||
`plugin_connected: true` and the protocol version.
|
||||
|
||||
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
|
||||
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
|
||||
the container.
|
||||
|
||||
## What it proved, first time
|
||||
|
||||
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
|
||||
|
||||
```
|
||||
web server listening addr=0.0.0.0:21004
|
||||
plugin connected peer=127.0.0.1:51148
|
||||
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
|
||||
```
|
||||
|
||||
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
|
||||
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
|
||||
answered `plugin_connected: true`.
|
||||
|
||||
## Still open for phase 18
|
||||
|
||||
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
|
||||
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
|
||||
what R22's variable block is for.
|
||||
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
|
||||
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
|
||||
node, so this is a walk to run, not a claim to repeat.
|
||||
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
|
||||
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
|
||||
of this, and still the reason a rig can differ from itself between two runs.
|
||||
@@ -220,6 +220,69 @@ how long the sidecar keeps raw events. The permanent record — per-wipe totals
|
||||
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
||||
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
||||
|
||||
**From protocol 3 your players can link their Steam account.** In game they type `/link` and the
|
||||
server answers them privately with a six-character code; on the website they type that code in
|
||||
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
|
||||
website owns the record, and `/unlink` in game asks it to let go.
|
||||
|
||||
Two things an operator should know about it:
|
||||
|
||||
- **The code is never in a frame.** It reaches the player and nobody else, which is what makes typing
|
||||
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
|
||||
`account.link.requested`, a staff-visible note that somebody asked.
|
||||
- **A Steam account can belong to one website account at a time, across your whole fleet.** A code
|
||||
from any of your servers links for all of them. If somebody links the wrong account the site
|
||||
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
||||
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".
|
||||
|
||||
**From protocol 5 you can edit your plugins' settings from the website**, in Admin → Rust mod
|
||||
config. It reads the configuration directory your framework actually uses — `oxide/config` or
|
||||
`carbon/configs`, or wherever you moved it — and generates a form from the values it finds, so it
|
||||
works for whatever you have installed. Four things worth knowing before you use it:
|
||||
|
||||
- **A save reloads the plugin and watches the reload.** If the plugin does not come back within four
|
||||
seconds, the old file is **restored automatically** and the site shows you the log line that says
|
||||
why. A typo costs you a few seconds, not a plugin.
|
||||
- **Your data directory is not listed, deliberately.** `oxide/data` (or `carbon/data`) holds live
|
||||
state — kit cooldowns, zone definitions, the permission store itself — not settings. Editing it
|
||||
from a web form edits your players' cooldowns, and a running plugin overwrites the change on its
|
||||
next save anyway.
|
||||
- **Which plugin gets reloaded is your choice, with a guess filled in.** A folder name is
|
||||
convention, not contract, so the site suggests one and lets you change it. The suggestion is right
|
||||
nearly always and wrong silently when it is wrong, which is why it is a field rather than an
|
||||
assumption.
|
||||
- **This bridge's own `Host`, `Port` and `ServerId` are read-only there.** Changing them from the
|
||||
website would cut the link carrying the change, or strand every row the site holds for this
|
||||
server. Edit them on the host; everything else in that file is editable from the site.
|
||||
|
||||
`rg.config` at the server console prints which directory the site is reading and what the last write
|
||||
from it did.
|
||||
|
||||
**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
|
||||
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||
|
||||
@@ -98,3 +98,145 @@ Not "frames arrived". Three things, and the third is the one worth slowing down
|
||||
|
||||
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
||||
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
||||
|
||||
---
|
||||
|
||||
## The identity walk (protocol 3, phase 6)
|
||||
|
||||
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
|
||||
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
|
||||
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
|
||||
three steps below.
|
||||
|
||||
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
|
||||
link the same Steam account to.
|
||||
|
||||
| # | Do this | You should see |
|
||||
|---|---|---|
|
||||
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
|
||||
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
|
||||
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
|
||||
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
|
||||
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
|
||||
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
|
||||
|
||||
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
|
||||
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
|
||||
|
||||
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
|
||||
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
|
||||
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.
|
||||
|
||||
## The configuration walk (protocol 5, phase 7b)
|
||||
|
||||
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
|
||||
plugin over a real directory of real config files — the recursive walk, a form save, a rollback, a
|
||||
refusal, a version conflict and the locked keys — and the plugin half **compiles and loads on the
|
||||
live Oxide rig**, where `rg.config` answers
|
||||
`protocol=5 framework=oxide root=/home/container/oxide/config`.
|
||||
|
||||
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
|
||||
in the running game.** It needs the sidecar and the game server on **one host**, because the game
|
||||
link is loopback by design (D2).
|
||||
|
||||
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
|
||||
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
|
||||
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
|
||||
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
|
||||
other, which is the whole point of putting them in one container.
|
||||
|
||||
| # | Do this | You should see |
|
||||
|---|---|---|
|
||||
| 1 | **Open Admin → Rust mod config** and pick the server | The tree the framework actually uses — `oxide/config` on Oxide, `carbon/configs` on Carbon — grouped by plugin, with every loaded plugin's version beside it |
|
||||
| 2 | **Open `ZoneManager.json`, change a setting, leave the reload target on its guess, and save** | "Saved, and the plugin reloaded." At the console, `oxide.show`/`c.show` is irrelevant — the proof is the plugin behaving differently, so pick a setting you can see: `Auto Show Zones`, or an entry message |
|
||||
| 3 | **Check a float nobody touched**, e.g. a rate ending `.0`, in the file on the host | It is still `1.0`, not `1`. This is the trap the whole editor exists for, and a server whose configs are full of whole-numbered floats is where it bites |
|
||||
| 4 | **Break a config on purpose** — in Raw JSON, give a numeric field a string, or anything the plugin's own class cannot deserialize — and save with that plugin as the reload target | Within about four seconds: *"The plugin did not come back, so the old file was put back automatically"*, the compiler's own line underneath it, and the file on the host back as it was. `oxide.plugins` shows the plugin **loaded** — because the restore was reloaded too |
|
||||
| 5 | **Save a nested file** (`Kits/kits.json`, or any `config/<Mod>/x.json`) **and confirm the reload target** | The right plugin reloads. Reloading the wrong one is the failure this field exists to prevent, and it reports success — so check `oxide.plugins`' timestamps, not the website's word |
|
||||
| 6 | **Open the bridge's own config** | `Host`, `Port` and `ServerId` are read-only with the reason; `QueueCap` saves; the reload dropdown does not offer this plugin. The save says it was written and **not** reloaded, which is the honest answer — our settings apply on the next deliberate reload |
|
||||
| 7 | **Edit a file on the host over SSH while the website has it open, then save from the website** | A conflict, with the current file offered — never an overwrite |
|
||||
| 8 | **Ask for a file outside the tree** (`../data/oxide.users.data`, an absolute path) with `curl` against the sidecar, with a valid token | Refused by the **plugin**, with a reason. The sidecar forwards paths and judges none of them; the guard is where the directory is |
|
||||
|
||||
**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 rather than assume:
|
||||
|
||||
- **The reload path.** The plugin asks `Interface.Oxide` for `ReloadPlugin` by reflection and falls
|
||||
back to a console command — `c.reload` on Carbon, `oxide.reload` on Oxide, chosen by looking for a
|
||||
Carbon assembly at runtime. A wrong prefix on Carbon prints **nothing at all**, which looks
|
||||
exactly like a command that worked (`CARBON.md` §5), so the proof is `OnPluginLoaded` arriving,
|
||||
not the command being accepted.
|
||||
- **`OnPluginLoaded` / `OnPluginUnloaded` firing at all.** They are the rollback's only evidence. If
|
||||
either does not fire on a framework, every save there rolls itself back four seconds later and
|
||||
reports a plugin that is in fact perfectly fine. `rg.hooks` at the console is the standing answer:
|
||||
both names are in `ExpectedHooks`, so a framework that never raises one shows a zero.
|
||||
|
||||
**What counts as a pass:** a setting typed on the website changes what the running game does; a
|
||||
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
|
||||
the save did not touch differs by a single byte.
|
||||
|
||||
## The account walk on a phone (phase 8, Android leg B)
|
||||
|
||||
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
|
||||
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
|
||||
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
|
||||
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
|
||||
same three minutes as the identity walk above, done on the phone instead of in a browser.
|
||||
|
||||
| # | Do this | You should see |
|
||||
|---|---|---|
|
||||
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
|
||||
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
|
||||
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
|
||||
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
|
||||
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
|
||||
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
|
||||
|
||||
**What counts as a pass:** a player links an account from the phone without touching a browser, and
|
||||
the screen never claims an entitlement is in the game when the site has not confirmed it there.
|
||||
|
||||
@@ -50,8 +50,8 @@ it is listening without one.
|
||||
|
||||
## 2. Versioning
|
||||
|
||||
The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places
|
||||
that must agree:
|
||||
The wire version is a single integer — **8** as of the leases (§14) — declared in
|
||||
**four** places that must agree:
|
||||
|
||||
| Where | Repo |
|
||||
|---|---|
|
||||
@@ -205,6 +205,7 @@ Every response carries `X-RustLink-Version`, including `/health` and including e
|
||||
| `GET /feed?since=&limit=` | the store | **Oldest first**, from a cursor. For a consumer that must not miss a row (§8.9) |
|
||||
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
|
||||
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
|
||||
| `GET /lease`, `POST /lease`, `POST /lease/release` | the plugin (RPC) | Protocol 8, the leases (§14) |
|
||||
|
||||
### 4.1 The split between store-backed and live is deliberate
|
||||
|
||||
@@ -248,6 +249,9 @@ sidecar RPC timeout (10s) < module client timeout (12s) < an action's budget
|
||||
|
||||
Derive one from another rather than writing all three down independently.
|
||||
|
||||
**Lease calls are the exception, and a deliberate one** (§14.7): `core.lease` spends one default
|
||||
budget on two calls, so the module's lease timeout is *shorter* than the sidecar's.
|
||||
|
||||
---
|
||||
|
||||
## 5. What the plugin owes the game
|
||||
@@ -330,12 +334,14 @@ writing the file and generating the token if they are missing — and prints it
|
||||
|
||||
## 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
|
||||
it, and each is a version bump:
|
||||
Protocol 4 is the transport, the read path, identity and the permission mirror. Every one of these
|
||||
arrives with the phase that needs it, and each is a version bump:
|
||||
|
||||
- identity and the in-game link code (phase 6)
|
||||
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
|
||||
- clans, for core's Team provider (phase 9)
|
||||
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
||||
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||||
- ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11**
|
||||
- ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12**
|
||||
- ~~who lives in a raided base, for the raid alert (phase 10)~~ — **protocol 7, §13**
|
||||
- leases, budgets and the event actions (phases 12-13)
|
||||
- the map image over the asset-bridge shape (phase 14)
|
||||
|
||||
@@ -444,13 +450,13 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
|
||||
|
||||
| `kind` | Hook | `class` | Carries |
|
||||
|---|---|---|---|
|
||||
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
|
||||
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
|
||||
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
|
||||
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
|
||||
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
|
||||
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
|
||||
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
|
||||
| `player.connected` | `OnPlayerConnected` | **presence** | steamId, name |
|
||||
| `player.disconnected` | `OnPlayerDisconnected` | **presence** | steamId, name, reason, sessionSec |
|
||||
| `player.respawned` | `OnPlayerRespawned` | **presence** | steamId |
|
||||
| `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
|
||||
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
|
||||
| `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
|
||||
| `entity.destroyed` | `OnEntityDeath` on owned building blocks — **and doors, external walls and the cupboard from protocol 7 (§13)** | **staff** | ownerId, prefab, grid, attacker; from protocol 7 also `structure`, `buildingId`, `authorized` |
|
||||
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
||||
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
||||
| `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** |
|
||||
@@ -458,6 +464,8 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
|
||||
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
||||
| `server.initialized` | `OnServerInitialized` | public | — |
|
||||
| `server.shutdown` | `OnServerShutdown` | public | — |
|
||||
| `account.link.requested` | `/link` chat command *(protocol 3)* | **staff** | steamId, name, ttlSec — **never the code** |
|
||||
| `account.unlinked` | `/unlink` chat command *(protocol 3)* | **staff** | steamId, name, origin |
|
||||
|
||||
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
|
||||
and every community site shows it; a **structure's** grid is where somebody lives, which is why
|
||||
@@ -483,6 +491,14 @@ So: the table in §8.4 is the specification, `module-rust` holds the allowlist,
|
||||
its allowlist against this document, so adding a kind here without classifying it there fails a
|
||||
build rather than shipping an IP address to a public page.
|
||||
|
||||
**`presence` is `public` with an audience an operator chooses** (added 2026-09-22, [`PLAN.md`](../modules/rust/PLAN.md) §23).
|
||||
The six kinds marked so each say that a *named* player was on the server at a given moment, and the
|
||||
org lead's rule is that nothing names who is online by default: `module-rust` serves them only to
|
||||
viewers inside an operator-chosen audience — staff unless widened, fleet-wide with a per-server
|
||||
override. Below it the public feed carries only what names nobody (a wipe, a start, a shutdown).
|
||||
Nothing on the wire changed: the class is still the module's to enforce, which is why the rule could
|
||||
be added without a protocol bump.
|
||||
|
||||
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
|
||||
`player.reported` carries the text of one player's complaint about another. They are stored because
|
||||
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
|
||||
@@ -585,3 +601,801 @@ a game host is a wipe-day outage waiting for a busy month.
|
||||
|
||||
---
|
||||
|
||||
## 9. Protocol 3 — identity
|
||||
|
||||
R1's identity link, and the first message in this bridge that the **website** originates. Everything
|
||||
in protocol 2 was the game talking, or the sidecar asking the game to repeat something it already
|
||||
knew.
|
||||
|
||||
The shape is the one the UO bridge proved: the player asks in game, the plugin mints a one-time code
|
||||
and hands it to them privately, and the website redeems it through the sidecar.
|
||||
|
||||
```
|
||||
player plugin sidecar website
|
||||
│ /link │ │ │
|
||||
├────────────────────►│ mint code, hold it │ │
|
||||
│◄────── code ────────┤ in memory, 5 min │ │
|
||||
│ ├─ account.link.requested ►│ ───── feed ───────►│
|
||||
│ │
|
||||
│ ………… the player types the code into the website ……………………………►│
|
||||
│ │ │◄ POST /link/confirm ┤
|
||||
│ │◄──── link.confirm ───────┤ │
|
||||
│ ├───── link.ok ───────────►│ ── steamId, name ──►│
|
||||
│ │ (code spent) │ │
|
||||
```
|
||||
|
||||
**Nothing about the link is stored in the game.** The site is the author of record, which is not a
|
||||
preference: there is no per-account store in Rust that survives a wipe, and phase 7 makes the site
|
||||
authoritative anyway — it pushes permissions *into* the game keyed by Steam id. A copy on the game
|
||||
host would be a second thing to reconcile every wipe, answering no question better.
|
||||
|
||||
### 9.1 `/link` and `/unlink` are CHAT commands, and the reply is private
|
||||
|
||||
`[ChatCommand("link")]`. Both frameworks consume a `/` command rather than broadcasting it, and
|
||||
`SendReply` addresses one player — so neither the request nor the code reaches anybody else's chat.
|
||||
That is load-bearing rather than polish: **a code read off a stream is a code somebody else can
|
||||
spend.**
|
||||
|
||||
`/unlink` emits rather than deletes, because the plugin holds no link to delete. It exists because
|
||||
the website **refuses** to move a Steam id another account already holds (D23): without a way out, a
|
||||
player who linked the wrong account while signed in as it would need staff. The authority on that
|
||||
path is the Steam account itself — whoever is connected to the game as it is who it is.
|
||||
|
||||
### 9.2 The code is **not** on the wire
|
||||
|
||||
`account.link.requested` carries the Steam id, the name and the TTL, and **never the code**. The
|
||||
event exists so an operator can see linking being used and so the site can see a player fishing; it
|
||||
is not how the code travels. The code travels **through the player**, which is what makes typing it
|
||||
into a signed-in browser proof that they are the one who asked.
|
||||
|
||||
Both account frames are **staff** class (§8.5). Neither carries a secret, but both name a Steam id
|
||||
beside a website account's activity, and that join — *this player is that person* — is a fact about
|
||||
somebody's identity rather than about what happened on the server.
|
||||
|
||||
### 9.3 `link.confirm` — website → plugin
|
||||
|
||||
The first inbound command that is not a request to repeat something.
|
||||
|
||||
```json
|
||||
{ "cmd": "link.confirm", "reqId": "r-42", "code": "K7M2PQ" }
|
||||
```
|
||||
|
||||
Answered with `link.ok` carrying `steamId` and `name`, or `link.error` carrying a `reason` of
|
||||
`unknown`, `expired` or `malformed`. Both are replies, correlated by `reqId` like `server.status`.
|
||||
|
||||
**A code is consumed on the FIRST lookup, whether or not it turns out to be expired.** The removal
|
||||
happens before the expiry check rather than after it, so a code cannot be probed twice.
|
||||
|
||||
**`unknown` and `expired` are separate here and identical to the player.** An operator reading a log
|
||||
wants to know whether codes are being guessed or merely going stale; a stranger typing codes must not
|
||||
learn which of the two they hit, because that is the difference between "keep guessing" and "guess
|
||||
faster".
|
||||
|
||||
### 9.4 The code itself
|
||||
|
||||
Six characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — **no O, 0, I or 1**, because a player reads
|
||||
this off their screen and types it into a browser, often on a phone. A five-minute TTL, a
|
||||
thirty-second cooldown per player, **one outstanding code each** (a new `/link` drops the old one),
|
||||
and a purge timer, because an unconfirmed code is never looked up and nothing else would ever remove
|
||||
it.
|
||||
|
||||
They live in plugin memory and nowhere else. A plugin reload drops every pending code — and phase
|
||||
7b's config editor will reload plugins routinely — but the cost of that is a player typing `/link`
|
||||
again, which is cheaper than an unconfirmed credential living in a second process.
|
||||
|
||||
### 9.5 `POST /link/confirm` — the first route on this sidecar that is not a GET
|
||||
|
||||
```
|
||||
POST /link/confirm { "code": "K7M2PQ" } → 200 { "kind": "link.ok", "steamId": "765…" }
|
||||
→ 200 { "kind": "link.error", "reason": "unknown" }
|
||||
→ 503 the game is not connected
|
||||
→ 504 the game is up and did not answer
|
||||
```
|
||||
|
||||
**A refused code is a `200`.** `link.ok` and `link.error` are both answers; the sidecar reserves its
|
||||
own status codes for the transport, because the website has to tell *"that code is wrong"* from
|
||||
*"the game never replied"* to say the right thing to a player (§4.3).
|
||||
|
||||
The sidecar validates nothing but the shape — it trims the code, bounds its length, and forwards it.
|
||||
Only the game holds the pending codes, and putting the table here instead would give the sidecar a
|
||||
credential and an opinion, which D2 and the bridge principles say it has neither of.
|
||||
|
||||
### 9.6 The website asks EVERY server (D24)
|
||||
|
||||
A code is minted by one server, and the player types six characters into a browser. Nothing in the
|
||||
code says which server it came from, so the module asks each configured server in turn and the first
|
||||
`link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent
|
||||
at the server that holds it.
|
||||
|
||||
Asking the player to pick was rejected: a wrong pick comes back indistinguishable from a wrong code.
|
||||
|
||||
The consequence for this protocol is worth stating, because it is the shape of every later
|
||||
fleet-wide command: **"every reachable server refused" is not the same answer as "a server could not
|
||||
be reached"**, and a module that collapses them tells the player whose server is down that their code
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 11. Protocol 5 — configuration from the site
|
||||
|
||||
R18, and the first command on this bridge that writes to the game host's
|
||||
**filesystem**. Protocol 4 wrote to a store the game owns through an API the game
|
||||
owns; this replaces bytes in a file and then asks the framework to read them.
|
||||
|
||||
```
|
||||
website sidecar plugin
|
||||
│ │ │
|
||||
├── GET /config/files ─────►│ ──── config.list ───────►│ walk ConfigDirectory
|
||||
│◄──── the tree ────────────│◄──── config.catalogue ───┤ (never DataDirectory)
|
||||
│ │ │
|
||||
├── GET /config/file ──────►│ ──── config.read ───────►│ one file + a version
|
||||
│ │ │
|
||||
├── POST /config/write ────►│ ──── config.write ──────►│ back up, write,
|
||||
│ whole file TEXT │ │ reload, WATCH
|
||||
│◄──── the report ──────────│◄──── config.report ──────┤ …or restore it all
|
||||
```
|
||||
|
||||
**The website composes the bytes and the plugin writes them.** That split is the
|
||||
one design decision everything else here follows from, and §11.5 is why.
|
||||
|
||||
### 11.1 The roots come from the framework, and one of them is forbidden
|
||||
|
||||
The walk is rooted at `Interface.Oxide.ConfigDirectory` — `oxide/config` on
|
||||
Oxide, `carbon/configs` on Carbon, and neither on a server whose operator moved
|
||||
it with `-carbon.configdir` ([`CARBON.md`](../modules/rust/CARBON.md) §3). It is
|
||||
never composed from a literal, and that amendment was proven the best way it
|
||||
could have been: this bridge's own config landed in **both** places, written by
|
||||
the same source file.
|
||||
|
||||
`DataDirectory` is **never walked**. It holds live state — kit cooldowns, zone
|
||||
definitions — and both frameworks' own permission stores (`oxide.users.data`,
|
||||
`oxide.groups.data`), which is protocol 4's mirror one directory over. A
|
||||
settings editor that strayed there would be editing §10 underneath itself.
|
||||
|
||||
### 11.2 `config.list` — a description of the tree, never its contents
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "config.catalogue", "type": "reply", "reqId": "r-7",
|
||||
"root": "/home/container/oxide/config",
|
||||
"self": "RunicGateway",
|
||||
"files": [
|
||||
{ "path": "ZoneManager.json", "bytes": 4210, "modified": 1758500000000,
|
||||
"plugin": "ZoneManager", "editable": true },
|
||||
{ "path": "Kits/kits.json", "bytes": 980, "modified": 1758400000000,
|
||||
"plugin": "Kits", "editable": true },
|
||||
{ "path": "Huge.json", "bytes": 9400000, "editable": false,
|
||||
"reason": "larger than this bridge will carry" }
|
||||
],
|
||||
"plugins": [ { "name": "ZoneManager", "title": "Zone Manager", "version": "3.1.14" } ],
|
||||
"truncated": false,
|
||||
"limits": { "depth": 6, "files": 500, "fileBytes": 262144, "writeFiles": 10 }
|
||||
}
|
||||
```
|
||||
|
||||
Four things about that shape are load-bearing.
|
||||
|
||||
**No file is hashed here.** A version is produced by `config.read`, on the one
|
||||
file somebody actually opened. Hashing 500 files would be up to 128 MB of reads
|
||||
in a single frame, which is the unbounded main-thread work §10.5 forbids — so
|
||||
this walk reads directory entries and nothing else.
|
||||
|
||||
**`plugin` is a GUESS and is labelled one all the way to the form.** It is the
|
||||
folder for a nested file and the filename otherwise, and a folder name is
|
||||
convention rather than contract. Infer it silently and the failure is the
|
||||
nastiest available here: the wrong plugin is reloaded, `OnPluginLoaded` fires for
|
||||
*it*, and the write is reported as a success while the plugin that was actually
|
||||
edited never re-read anything.
|
||||
|
||||
**A file past a limit is listed and marked, never hidden.** An operator who
|
||||
cannot find a file they know exists goes looking for a bug in the bridge; one who
|
||||
can see why it was refused does not.
|
||||
|
||||
**`self` is the plugin naming itself**, so the website can lock the three keys in
|
||||
*our* config that would cut this link (§11.6) without matching on a filename
|
||||
somebody may rename.
|
||||
|
||||
### 11.3 `config.read` — one file, and the version a write must present back
|
||||
|
||||
```json
|
||||
{ "kind": "config.file", "type": "reply", "reqId": "r-8",
|
||||
"path": "ZoneManager.json", "text": "{\n \"Auto Show\": true\n}",
|
||||
"version": "1a4-3f2c8a91b0de4471", "bytes": 420, "modified": 1758500000000 }
|
||||
```
|
||||
|
||||
`version` is the file's length and an FNV-1a hash of its text. It is deliberately
|
||||
**not** a cryptographic digest: nothing here is a security claim — the website
|
||||
never computes one, it only echoes back the one it was given — and
|
||||
`System.Security.Cryptography` is one more thing that would have to be available
|
||||
under two plugin compilers.
|
||||
|
||||
### 11.4 `config.write` — the set, the reload, and the undo
|
||||
|
||||
```json
|
||||
{ "cmd": "config.write", "reqId": "r-9",
|
||||
"files": [ { "path": "ZoneManager.json", "version": "1a4-3f2c…", "text": "{…}" } ],
|
||||
"reload": "ZoneManager" }
|
||||
```
|
||||
|
||||
The plugin, in order:
|
||||
|
||||
1. resolves and guards every path (§11.6), checks every version, and checks that
|
||||
every document parses — **before the first byte is written**. Same posture as
|
||||
`perm.sync`: a refusal that has touched nothing has nothing to unwind;
|
||||
2. backs each file up under `DataDirectory/RunicGateway/config-backups/`, keeping
|
||||
the last ten per file, and holds the original in memory for the rollback;
|
||||
3. writes the set;
|
||||
4. reloads the named plugin **through the framework**, not by composing a console
|
||||
string — Carbon's commands are `c.`-prefixed, an alias for the Oxide names is
|
||||
opt-in, and a wrong prefix on Carbon prints *nothing*, so it looks exactly
|
||||
like a command that worked;
|
||||
5. waits up to **four seconds** for `OnPluginLoaded` naming that plugin;
|
||||
6. if it arrives, re-reads each file and reports the new versions. If it does
|
||||
not, **restores every file, reloads again, and reports the failure with the
|
||||
tail of the server's newest log file.**
|
||||
|
||||
```json
|
||||
{ "kind": "config.report", "type": "reply", "reqId": "r-9",
|
||||
"ok": false, "reloaded": false, "rolledBack": true,
|
||||
"reason": "'ZoneManager' did not reload within 4s",
|
||||
"log": "…Error while compiling ZoneManager…",
|
||||
"files": [ { "path": "ZoneManager.json", "version": "1a4-…", "rewritten": false } ] }
|
||||
```
|
||||
|
||||
**That rollback is the feature.** Without it this is a web form that takes a
|
||||
required plugin off a production server one typo at a time — and four plugins are
|
||||
required (R6/R17), so a broken `ZoneManager` config is also event participation
|
||||
gone.
|
||||
|
||||
Three consequences worth naming:
|
||||
|
||||
- **The window is arithmetic, not taste.** The worst path is two windows — wait,
|
||||
give up, restore, wait again — and the caller holds a socket throughout. It
|
||||
must fit inside the sidecar's `REPLY_TIMEOUT` (§4.4, 10s), or the rollback
|
||||
report arrives after the only thing waiting for it has gone. The sidecar
|
||||
mirrors the number as `web::CONFIG_RELOAD_WINDOW` and a test asserts the
|
||||
inequality rather than trusting it.
|
||||
- **`rewritten` is normal.** Both frameworks merge missing defaults into a config
|
||||
on load and save it back, so the file after a successful reload is regularly
|
||||
not the file that was sent. The report says so; a website that assumed
|
||||
otherwise would conflict with itself on the next save.
|
||||
- **The bridge will not reload itself.** The reload would unload this plugin and
|
||||
close the link carrying the answer, leaving a rollback with nothing watching
|
||||
it — the one failure the mechanism exists to report would be the one it could
|
||||
not. `reload-self` is refused, and our own settings apply on the next
|
||||
deliberate reload instead.
|
||||
|
||||
### 11.5 JavaScript cannot tell `1` from `1.0`, so it never writes the number
|
||||
|
||||
`JSON.parse('{"Rate":1.0}')` yields `1` and `JSON.stringify` writes `1`. Both
|
||||
frameworks deserialize a config into typed C# classes, so a naive
|
||||
read-modify-write **silently rewrites every whole-numbered float as an integer,
|
||||
on fields nobody touched** — and Newtonsoft may coerce that or may throw. A throw
|
||||
at load is a plugin that does not come back.
|
||||
|
||||
So the website never parses, mutates and re-serialises. Its editor records the
|
||||
**source span** of every value and splices new literals into them, which is why
|
||||
`config.write` carries whole file text: the bytes on the wire are the bytes that
|
||||
will be on disk, and the fields nobody edited are byte-identical. A number's new
|
||||
value travels as the literal an admin typed, and never becomes a JavaScript
|
||||
number anywhere in the path.
|
||||
|
||||
The plugin's contribution to that is deliberately nothing beyond checking that
|
||||
the document parses. Giving this end an opinion about content would put the
|
||||
decision in two places, and only one of them can be tested against a real
|
||||
Newtonsoft.
|
||||
|
||||
### 11.6 Addressing by path is a new bug class, and it is guarded here
|
||||
|
||||
Protocol 4 addressed things by name. This addresses them by path, which is
|
||||
exactly the change that introduces traversal — so the plugin refuses a path that
|
||||
is absolute, carries a drive letter, contains `..`, does not end in `.json`, or
|
||||
does not resolve **under the canonicalised config root**. Links are not followed:
|
||||
any file or directory carrying a reparse point is skipped by the walk and refused
|
||||
by the resolver, because resolving one is how a tree that looks bounded turns out
|
||||
not to be.
|
||||
|
||||
The sidecar forwards the path verbatim and judges nothing, as it forwards a link
|
||||
code and a permission set. That is not laziness: only the process holding the
|
||||
directory can decide whether a path resolves inside it, and a guard in the middle
|
||||
would be a weaker second opinion in a place with no way to check it.
|
||||
|
||||
The website checks the *shape* before spending a round trip, and the bridge's own
|
||||
three keys — `Host`, `Port`, `ServerId` — are refused there rather than here,
|
||||
because "which file is ours" is a question about the website's configuration, not
|
||||
about the game's.
|
||||
|
||||
### 11.7 What the sidecar does NOT do
|
||||
|
||||
It stores nothing. Nothing from protocol 5 reaches the store or the feed: a
|
||||
config this sidecar cached would be an edit an operator made over SSH that the
|
||||
website then silently overwrote. All three routes fail when the game is down,
|
||||
like `/status`, because "what is on that host's disk" has no stale answer worth
|
||||
giving.
|
||||
|
||||
The one thing it adds is a better `504`. A timeout on `/config/write` is the only
|
||||
timeout on this bridge with a knowable answer, because the plugin writes a whole
|
||||
set or restores a whole set and never half of either — so the body says to
|
||||
re-read rather than to guess, and names the reload window that is probably still
|
||||
running.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 12. Protocol 6 — first-party clans
|
||||
|
||||
Phase 9, R5. Rust's **own** clan system becomes core's Teams: the plugin reports it, the module
|
||||
answers core's Team provider from it, and the website owns the clan page. Design of record:
|
||||
[`PLAN.md`](../modules/rust/PLAN.md) §24 (D47–D58).
|
||||
|
||||
It is not the uMod **Clans** plugin. That is a separate system that never touches the game's
|
||||
`ClanManager` (D47), so a server running it has two unrelated clan systems, and only the game's
|
||||
becomes Teams. The plugin reads nothing of it except whether it is loaded.
|
||||
|
||||
**The sidecar changed nothing but its version.** One board and five events, filed by `type` (§8.1).
|
||||
|
||||
### 12.1 The `clans` board
|
||||
|
||||
A snapshot, re-sent on connect, on the 60-second cadence, and about three seconds after any clan
|
||||
hook fires, so a roster follows the change that caused it.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "clans", "type": "snapshot", "t": 1790158748054, "serverId": "rust-oxide",
|
||||
"enabled": true, "backend": "LocalClanBackend", "supported": true, "truncated": false,
|
||||
"umodClans": false, "count": 1,
|
||||
"clans": [{
|
||||
"clanId": 1, "createdMs": 1790158729260, "name": "Northwatch", "color": "#3fa9f5",
|
||||
"score": 0, "maxMembers": 100,
|
||||
"members": [{ "steamId": "76561190000000001", "rank": 1, "role": "Leader", "joinedMs": 1790158729264, "name": "…" }]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `enabled` | The game's `clan.enabled` convar. `false` is an authoritative answer — no clans — and is `supported` |
|
||||
| `supported` / `reason` | Could the plugin read the clans at all. `false` when the backend has not started, or is not the local one (a **Nexus** server keeps its clans elsewhere) — refused with a reason rather than guessed at |
|
||||
| `truncated` | There may be clans the board does not list. See §12.3 |
|
||||
| `umodClans` | The uMod Clans plugin is loaded. The website warns that its clans are not Teams |
|
||||
| `rank` | The member's role rank. **Rank 1 is leader**, and several members may hold it. Absent when the member's role id matched no role — never defaulted |
|
||||
| `name` | Present when the framework knows the player. Absent, not empty, otherwise |
|
||||
|
||||
A member's `LastSeen` is **not sent**. It is presence, and nothing names who is online by default
|
||||
(PLAN.md §23).
|
||||
|
||||
### 12.2 The five events
|
||||
|
||||
| `kind` | Hook | Carries |
|
||||
|---|---|---|
|
||||
| `clan.created` | `OnClanCreated(LocalClan, ulong)` | clan, founder (`steamId`, `name`) |
|
||||
| `clan.disbanded` | `OnClanDisbanded(LocalClan, ulong)` | clan, who disbanded it |
|
||||
| `clan.member.added` | `OnClanMemberAdded(long, ulong)` | clan, the new member |
|
||||
| `clan.member.left` | `OnClanMemberLeft(LocalClan, ulong)` | clan, the member |
|
||||
| `clan.member.kicked` | `OnClanMemberKicked(LocalClan, ulong, ulong)` | clan, the member, and who kicked them (`bySteamId`, `byName`) |
|
||||
|
||||
"Clan" is always `clanId`, `createdMs` and `clanName`. Every one is **`staff` class** in §8.5's
|
||||
terms: clan membership is members-only (D49), so the public feed never carries it. It reaches a
|
||||
clan's members through core's Team feed, where core decides who is a member.
|
||||
|
||||
`OnClanColorChanged` is hooked too, but produces no event: a colour is a property of the clan, so it
|
||||
travels on the board, and the hook only brings the next board forward.
|
||||
|
||||
Three facts the hook sites impose, all read from the game's assemblies:
|
||||
|
||||
- **The founder's membership fires no `OnClanMemberAdded`.** The game adds them inside the
|
||||
creation, so `clan.created` implies it.
|
||||
- **`OnClanMemberAdded` hands over a bare id**, fired from inside the database layer before the
|
||||
game's cached clan is refreshed. The plugin reads the clan back for `createdMs` and `clanName`; if
|
||||
even that fails the frame carries `clanId` alone and the website matches on it.
|
||||
- **The game raises no promote or demote hook.** Leadership travels on the board only, and the
|
||||
website diffs one board against the next (D54).
|
||||
|
||||
### 12.3 Identity, and the ceiling
|
||||
|
||||
**A clan's identity is `clanId` AND `createdMs`.** The game keeps clans in `clans.<version>.db` with
|
||||
the version hard-coded, so a game update that bumps it starts a fresh file whose ids restart at 1.
|
||||
The website keys a Team on `<serverId>:<clanId>:<createdMs>` (D52), and every clan frame carries
|
||||
both halves for that reason.
|
||||
|
||||
**The game has no "list every clan" call.** Its backend offers get-by-id and get-by-member; the only
|
||||
listing is the clan leaderboard, which runs `SELECT … ORDER BY score DESC LIMIT ?` with the limit
|
||||
**clamped to 100**. So the board lists at most the top 100 clans by score. A board at that ceiling
|
||||
cannot be told apart from one with exactly 100 clans, and says `truncated: true` either way. It also
|
||||
says so if its rows would pass **768 KiB**, well inside the sidecar's 1 MiB line cap, which drops a
|
||||
longer line outright — a board that never arrived would read as a server with no clans.
|
||||
|
||||
The org lead accepted the ceiling (D55) over reading the game's private SQLite schema directly. A
|
||||
truncated board is answered to core as partial, so core adds and updates Teams there and never
|
||||
removes one on its word.
|
||||
|
||||
### 12.4 A hook name another plugin also raises
|
||||
|
||||
The uMod Clans plugin raises `OnClanDisbanded(string tag, List<ulong> members)`, and a Universal
|
||||
form with `List<string>`. Both have **the same name and arity** as the game's
|
||||
`OnClanDisbanded(LocalClan, ulong)`. The bridge declares the game's types exactly, and the framework
|
||||
matches a call to a method by its argument types, so neither call reaches it.
|
||||
|
||||
**Walked on the Oxide rig (2026-09-23):** both uMod-shaped calls were raised from a rig plugin, and
|
||||
the bridge's own `rg.hooks` count for `OnClanDisbanded` stayed at the one real disband, with nothing
|
||||
logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the
|
||||
game's.
|
||||
|
||||
## 13. Protocol 7 — the raid frame names who lives there
|
||||
|
||||
Added in phase 10 ([`PLAN.md`](../modules/rust/PLAN.md) §25). The raid alert goes to the people whose
|
||||
base it was (D59), and protocol 2's `entity.destroyed` could not say who that is: it named the
|
||||
block's **placer** (`ownerId`), which is not the base's owner in any sense a Rust player recognises,
|
||||
and it fired only for `BuildingBlock` — which a door is not. **No new kind and no new route;** one
|
||||
frame widens, and `clan.disbanded` gains its roster.
|
||||
|
||||
### 13.1 `entity.destroyed`, widened
|
||||
|
||||
It now fires, still only when a real (non-NPC) player did it and `OwnerID` is non-zero, for four
|
||||
kinds of entity. The kind travels as `structure`:
|
||||
|
||||
| `structure` | Game type | Notes |
|
||||
|---|---|---|
|
||||
| `block` | `BuildingBlock` | as before; the only kind the `structures` tally counts, so that column keeps its meaning |
|
||||
| `door` | `Door` (an `AnimatedBuildingBlock`, a *sibling* of `BuildingBlock`) | external gates are doors too |
|
||||
| `wall` | `SimpleBuildingBlock` | external walls |
|
||||
| `cupboard` | `BuildingPrivlidge` | the tool cupboard itself; `OnEntityDeath` runs before the kill, so it still reports its own list |
|
||||
|
||||
Two fields are added when the entity resolves to a cupboard (`DecayEntity.GetBuildingPrivilege()`,
|
||||
which goes through the building, or the cupboard itself):
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `buildingId` | the cupboard's network id, as a string — the base's identity, and the raid alert's cooldown subject |
|
||||
| `authorized` | `[{ steamId, online }]` from the cupboard's `authorizedPlayers`, **bounded at 64**; `authorizedTruncated: true` when cut |
|
||||
|
||||
**Both are ABSENT when there is no cupboard**, which is a different answer from an empty list, and
|
||||
the website alerts nobody in that case (D67). `recentGroupMembers` — which also sits on the cupboard
|
||||
— is **not** authorisation: it counts code-lock users toward group upkeep, and it is not sent.
|
||||
|
||||
`attackerId` is now derived from the player's `userID` rather than `UserIDString`, which the game
|
||||
fills in only for a connected player, a loaded sleeper or an engine bot. The website skips the alert
|
||||
when the attacker is on the cupboard (a self-demolish, a teammate), and a null would defeat that.
|
||||
|
||||
The class is unchanged: **staff**. A structure's grid is where somebody lives, and the frame now also
|
||||
names who. It reaches a player only through the raid alert, which is ceilinged `owner` and sent one
|
||||
person at a time.
|
||||
|
||||
### 13.2 `clan.disbanded` carries `members`
|
||||
|
||||
The Steam ids of the clan it ended. The website tells a disbanded clan's members, and by the time it
|
||||
reads the frame the next `clans` board may already have removed the roster from its store — the board
|
||||
is re-sent seconds after the event, and after an outage it is applied before the backlog. The game
|
||||
deletes the clan and walks `Members` to drop each membership, but never empties the list, so it is
|
||||
whole when the hook fires. Bounded by the clan's own member limit.
|
||||
|
||||
### 13.3 The sidecar
|
||||
|
||||
`PROTOCOL_VERSION` becomes 7 and nothing else changes: both frames are `event`s, stored and served as
|
||||
they arrive (§8.1). The bump exists because a website that alerts on `authorized` must not pair with
|
||||
a protocol-6 plugin that never sends it — against one it would read every raid as a base with no
|
||||
cupboard and alert nobody while looking healthy.
|
||||
|
||||
## 14. Protocol 8 — the leases
|
||||
|
||||
Added in phase 12 ([`PLAN.md`](../modules/rust/PLAN.md) §27). An event borrows a value on a server
|
||||
and gives it back. **Three commands, one event and one plugin config key.** The plugin holds the
|
||||
allowlist, the bounds, the seven-day ceiling and the deadline. The website holds the ledger (core's
|
||||
`core.lease`). This process forwards three routes and learns nothing about either side.
|
||||
|
||||
The shape is UO's lease plane (`link/v6.md` §8), and its three rules carry over unchanged:
|
||||
|
||||
- **`holdMs` is authoritative and `untilMs` is display.** An absolute deadline computed on the
|
||||
website and honoured on the game host is measured against two clocks.
|
||||
- **Values cross as text and compare parsed.**
|
||||
- **A hold over the ceiling is refused, never clamped.**
|
||||
|
||||
What differs from UO is what Rust's convars and permission store are like (§14.4).
|
||||
|
||||
### 14.1 The commands
|
||||
|
||||
```json
|
||||
{"cmd":"lease.apply","reqId":"r-7","key":"decay.scale","family":"decay","value":"0",
|
||||
"holdMs":3600000,"untilMs":1790000000000}
|
||||
```
|
||||
|
||||
| Command | Answers | |
|
||||
|---|---|---|
|
||||
| `lease.list` | `lease.list` | Every allowlisted key with `family`, `min`/`max`, `current` (or `unreadable` with a reason), `held`, and while held `baseline`/`applied`/`untilMs`/`runId`. Plus `holds` (every hold in force), `eventsEnabled` and `maxHoldMs`. **Narrowed by `key`, and by `target` for a group permission**, which has one value per pair rather than one per key |
|
||||
| `lease.apply` | `lease.ok` or `lease.error` | `lease.ok` carries `baseline`, `applied` and `untilMs` |
|
||||
| `lease.release` | `lease.ok`, `lease.drifted` or `lease.error` | Compare-and-set. `lease.ok` carries `restored`, or `targetGone: true` for a group deleted mid-hold |
|
||||
|
||||
**The allowlist is the plugin's.** It holds `decay.scale` (family `decay`, 0–10), eighteen animal
|
||||
and vehicle `*.population` convars (family `population`, 0–50, **all per square kilometre**, vehicles
|
||||
included, whatever the game's help text says), and `spawn.min_rate` and `spawn.min_density` (family
|
||||
`spawn`, 0–10). Every key was walked live: set, seen changing the game's own computation, and given
|
||||
back. The two `spawn.max_*` scalars only matter with players online, and no walk has had any, so
|
||||
they are not lent (PLAN.md §27.5). A `family` sent with an apply must match, so a website that confused two
|
||||
leases is refused rather than obeyed. The one key that is not a convar is `group.permission`, whose
|
||||
`target` is `group/permission` (split at the **last** slash, because a group name is free text and
|
||||
a permission name never contains one) and whose value is `true` or `false`. **The plugin grants with
|
||||
a `null` owner.** Given an owner, Oxide's `GrantGroupPermission` first checks that *that* plugin
|
||||
registered the name, and returns silently when it did not. Every permission a lease borrows
|
||||
belongs to another plugin, so the call has to name none.
|
||||
|
||||
**`lease.error` reasons**, each with a `message` meant for an operator:
|
||||
|
||||
| `reason` | Means | Worth retrying |
|
||||
|---|---|---|
|
||||
| `events-disabled` | `EventsEnabled` is off on this server (§14.3) | no |
|
||||
| `unknown-key` | not a value this server lends, or not in the family named | no |
|
||||
| `out-of-range` | outside the plugin's own bounds for the key | no |
|
||||
| `too-long` | `holdMs` over seven days | no |
|
||||
| `unresolved` | a group permission naming a permission no loaded plugin registered | no |
|
||||
| `target-gone` | the group does not exist | no |
|
||||
| `malformed` | a field is missing or unparseable | no |
|
||||
| `unreadable` | the current value could not be read this moment | yes |
|
||||
| `refused` | the game did not take the value: it read back as something else, and the old value was put back | yes |
|
||||
|
||||
### 14.2 The two mechanisms
|
||||
|
||||
**The deadline lives on the game.** A hold is checked every second. When its deadline passes, the
|
||||
plugin restores the baseline (compare-and-set, as a release would) and emits `lease.expired`, whether
|
||||
or not the website is ever heard from again:
|
||||
|
||||
```json
|
||||
{"kind":"lease.expired","type":"event","key":"decay.scale","runId":"77","drifted":false}
|
||||
```
|
||||
|
||||
The website maps it to nothing. Core learns what happened through `restore` and `inForce`, just as
|
||||
UO's website does.
|
||||
|
||||
**Release is compare-and-set.** The comparison is against what the lease applied, taken from the
|
||||
plugin's own record of the hold when it has one, else from the website's `expected`. A current value
|
||||
that is neither what was applied nor what would be restored was moved by somebody on purpose. The
|
||||
answer is `lease.drifted` with that value, the world is left alone, and the hold is over. A current
|
||||
value that already equals the baseline is a success and nothing is written, which is what a release
|
||||
finds after a deadline or a restart has already given the value back. **A drifted release is a
|
||||
`200`**: the plugin did what it was asked.
|
||||
|
||||
**An apply of a key already held keeps the original baseline.** Core reserves the target before it
|
||||
applies, so a second holder is refused on the website's side. A second apply arriving here therefore
|
||||
means the first one's answer was lost and core is trying again. The value to give back is still what
|
||||
was there before anybody borrowed it.
|
||||
|
||||
### 14.3 `EventsEnabled`
|
||||
|
||||
A new key in the plugin's config, **`false` by default** (D76), written into an existing config the
|
||||
first time protocol 8 loads so that the site's config editor can show it. It gates **`lease.apply`
|
||||
only**. Listing and releasing always work, so switching events off never strands a value somebody
|
||||
already borrowed.
|
||||
|
||||
It is its own switch for UO's reason: a scheduled change to the world at four in the morning is a
|
||||
different consent from a permission sync or a moderation action.
|
||||
|
||||
### 14.4 What a restart gives back, and what it does not
|
||||
|
||||
**No allowlisted convar is `Saved`.** The game writes the `Saved` set to `serverauto.cfg`, and none of
|
||||
these is in it. So a convar hold is memory-only, and **a restart is a free restore**. The plugin
|
||||
checks rather than trusts: at load it refuses any allowlisted convar whose `Command.Saved` is true,
|
||||
with a reason in `unreadable`.
|
||||
|
||||
**A group permission is persisted by both frameworks**, so a hold on one survives a crash, a restart
|
||||
and a plugin reload. The plugin therefore keeps its own record, `leases.json` under its data
|
||||
directory (which R18's editor never walks), with the `bootId` each hold was taken under:
|
||||
|
||||
| On load | A convar hold | A group-permission hold |
|
||||
|---|---|---|
|
||||
| **Same boot** (a plugin reload) | re-armed: the value is still in the game's memory, and a config save must not end an event | re-armed |
|
||||
| **New boot** | dropped: the restart restored it | re-armed, and restored at once if its deadline passed while the server was down |
|
||||
|
||||
Holds are **not** given back on unload. The file keeps them.
|
||||
|
||||
### 14.5 The permission mirror defers to a lease
|
||||
|
||||
A `(group, permission)` pair held by a lease belongs to the lease until the hold ends. `perm.sync`
|
||||
neither grants nor revokes it, and the scan never reports it `foreign`. It is listed in the report's
|
||||
new **`leased`** array. What the site asked for in the meantime is recorded on the hold, and at
|
||||
release the pair is set to **that** rather than to the baseline: the lease borrowed the pair, and the
|
||||
site owns what it becomes afterwards. The plugin's own lease writes raise no `perm.drift`.
|
||||
|
||||
### 14.6 The sidecar
|
||||
|
||||
`PROTOCOL_VERSION` becomes 8. Three routes, each a correlated round trip that fails when the game is
|
||||
down:
|
||||
|
||||
| Route | Command | |
|
||||
|---|---|---|
|
||||
| `GET /lease?key=&target=` | `lease.list` | Both query fields optional and forwarded as they are |
|
||||
| `POST /lease` | `lease.apply` | Opaque object; `cmd` and `reqId` written over the caller's |
|
||||
| `POST /lease/release` | `lease.release` | The same |
|
||||
|
||||
`lease.expired` is an `event`, filed and served like every other (§8.1).
|
||||
|
||||
### 14.7 The website's timeout, again
|
||||
|
||||
`core.lease` declares no `budgetMs`, so it runs under the dispatcher's default of **10 s**, and it
|
||||
makes **two** calls into the module inside that (`read`, then `apply`). `module-rust` therefore gives
|
||||
lease calls their own client timeout of **4.5 s** (`LEASE_TIMEOUT_MS`), so that two fit inside the
|
||||
budget. A test asserts the sum.
|
||||
|
||||
That is below the sidecar's 10 s reply timeout, so the module can give up on an apply the game is
|
||||
still going to take. It follows a timed-out apply with a release of the same value down the same
|
||||
link. The plugin handles the two in order: if the apply landed, the hold's own baseline goes back,
|
||||
and if it never did, the compare finds nothing to do.
|
||||
|
||||
Reference in New Issue
Block a user