feat(rust): site-owned permissions — the site is the author, the game is the cache #8

Merged
whitlocktech merged 2 commits from feat/phase-7-permissions into edge 2026-09-22 06:53:22 +00:00
Member

R2, and the first phase where this module writes to a game. Groups and grants are authored on the website and pushed into each server's own permission store, so every plugin that already calls UserHasPermission honours them with no adapter — and a wipe stops being a data-loss event, because the game forgets and the site does not.

Depends on Rust-Link#4 and Rust-Plugins#4 (protocol 4).

Seven org-lead decisions

D28 A grant is keyed to the website user, resolved to every Steam id they have linked at push time
D29 Every authored row carries a scope — one server, or * for the fleet
D30 Groups are mirrored as real groups, not flattened into per-player grants
D31 A holder the site did not author is reported, never undone; adopt or revoke is a person's decision
D32 One verb: the site sends the whole desired set and the plugin diffs locally
D33 A permission no server has registered is reported unresolved and never self-registered
D34 Authoring is people and groups by hand; rules that keep themselves true are deferred

Three sets, and every interesting question is a difference between two

desired − pushed    apply
pushed  − desired   RETIRE — the site put it there and has since withdrawn it
present − desired   drift  — somebody else put it there

The middle one is why rust_perm_pushed exists, and it cannot be inferred: a name in the store that is not in the desired set is either something the site retired or something a human granted, and those two have opposite correct answers. The site's memory of its own authorship is the only thing that tells them apart.

What lands is not what was sent

Two states are invisible from a push that looked like it worked, and neither is recorded as pushed:

  • a grant naming a permission the server has not registered — GrantUserPermission no-ops silently, so the site would otherwise believe it gave a privilege it did not;
  • a member the store has never seen — there is no user record to put in a group yet (§12.2 rule 4), and the membership lands on their first connection.

A direct grant to an unseen player works immediately, which is the asymmetry R16's offline entitlement rests on and the reason groups are not the only shape.

The loop

Every thirty seconds it asks a cheap question — does the digest of the desired set still equal what this server last confirmed — and does nothing when the answer is yes. A sync happens on a change, a restart, a wipe, a perm.drift hook from the game, a failed attempt past its backoff, or the fifteen-minute audit that finds drift on a server nobody has touched. The digest is sorted before hashing, because an unsorted one differs between two reads of an unchanged set and would push to every game server for ever.

Nothing in a request writes to a game: the buttons write to the site and the loop reconciles. Sync now is the exception and says so — an operator who has just changed something should not have to trust a timer to find out that a host is unreachable.

This module's first admin page

Everything before it was configured through the API, because nothing until now had to be authored. What is on the page is decided by what an operator can get wrong — four states that are invisible from the game and from a list of grants, each a sentence rather than a number:

  • a grant against somebody who has linked no Steam account (authored, stored, pushed nowhere);
  • a permission nothing has registered;
  • a member waiting on their first connection, named next to the person;
  • a server whose last sync failed, with the transport word that tells a wrong URL from a wrong token.

The admin.users.detail slot phase 6 built gains the same thing for one person, which is where an operator actually asks "why can this player spawn a kit".

Four defects the browser found that 133 green tests did not

  1. A person with permissions and no linked Steam account had the whole Rust section hidden on core's user page — the section was gated on links alone, and that person is precisely the one an operator needs to see, because their grants reach nobody.
  2. A member waiting on a first connection looked like an ordinary member. The count was in the server strip and nothing was beside the person.
  3. A grant naming an unregistered permission carried no warning, though the group's permission list had carried one all along.
  4. The page drew its own <h1> under core's chrome title — the same words twice.

A fifth was found by a test rather than a browser, and it was the better catch: buildDesired resolved a grant's accounts from the join in listGrants rather than from the link map, which gives the right answer by accident until somebody changes that query and one of a person's two accounts quietly stops being granted.

What was proven, and how

A real core at the pinned ref with this module installed, the real rust-link sidecar, and a stand-in plugin speaking protocol 4 on the loopback — enough to walk everything the site owns:

  • a group created, its permission added and two memberships applied on the first sync;
  • unresolved: ["kits.gold"] and pending: ["7656003:vip"] reported and kept out of the pushed ledger;
  • a hand edit found as drift, adopted (and the refusal for one whose Steam id belongs to nobody: "there is nobody to author this against"), and revoked — queued, carried on the next sync, and cleared;
  • a withdrawn grant retired from the game (pushed − desired);
  • a restart that emptied the store: the loop saw reason: "restart", re-pushed the whole set, and the group, its permission, both memberships and the grant came back — which is R2's central promise;
  • a perm.drift frame from the game marking the server dirty, and the sync after it reporting the hand edit authoritatively;
  • an unreachable second server reporting transport-error and retrying on its backoff while the first one stayed in sync.

Not proven, deliberately: that a site grant gates a third-party plugin in game. That acceptance line needs a second, non-admin Steam account on the rig (PLAN.md §12.5) — the org lead's call to arrange — so it is written down as a walk to run rather than claimed.

Checks

133 server tests, 39 client tests, check:imports, check:bundle, check:swagger, check:externals — all green. swagger-fragment.json (28 paths) and routes.manifest.json (30 routes, all documented) regenerated against a real core at the pinned ref.

Base branch

edge, per D18.

AI disclosure

  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM

R2, and the first phase where this module **writes** to a game. Groups and grants are authored on the website and pushed into each server's own permission store, so every plugin that already calls `UserHasPermission` honours them with no adapter — and a wipe stops being a data-loss event, because the game forgets and the site does not. Depends on Rust-Link#4 and Rust-Plugins#4 (protocol 4). ## Seven org-lead decisions | | | |---|---| | **D28** | A grant is keyed to the website **user**, resolved to every Steam id they have linked at push time | | **D29** | Every authored row carries a scope — one server, or `*` for the fleet | | **D30** | Groups are mirrored as **real** groups, not flattened into per-player grants | | **D31** | A holder the site did not author is **reported**, never undone; adopt or revoke is a person's decision | | **D32** | One verb: the site sends the whole desired set and the plugin diffs locally | | **D33** | A permission no server has registered is reported unresolved and **never self-registered** | | **D34** | Authoring is people and groups by hand; rules that keep themselves true are deferred | ## Three sets, and every interesting question is a difference between two ``` desired − pushed apply pushed − desired RETIRE — the site put it there and has since withdrawn it present − desired drift — somebody else put it there ``` The middle one is why `rust_perm_pushed` exists, and it cannot be inferred: a name in the store that is not in the desired set is **either** something the site retired **or** something a human granted, and those two have opposite correct answers. The site's memory of its own authorship is the only thing that tells them apart. ## What lands is not what was sent Two states are invisible from a push that looked like it worked, and neither is recorded as pushed: - a grant naming a permission the server has not registered — `GrantUserPermission` no-ops silently, so the site would otherwise believe it gave a privilege it did not; - a member the store has never seen — there is no user record to put in a group yet (§12.2 rule 4), and the membership lands on their first connection. A **direct grant** to an unseen player works immediately, which is the asymmetry R16's offline entitlement rests on and the reason groups are not the only shape. ## The loop Every thirty seconds it asks a cheap question — does the digest of the desired set still equal what this server last confirmed — and does nothing when the answer is yes. A sync happens on a change, a restart, a wipe, a `perm.drift` hook from the game, a failed attempt past its backoff, or the fifteen-minute audit that finds drift on a server nobody has touched. The digest is sorted before hashing, because an unsorted one differs between two reads of an unchanged set and would push to every game server for ever. Nothing in a request writes to a game: the buttons write to the site and the loop reconciles. *Sync now* is the exception and says so — an operator who has just changed something should not have to trust a timer to find out that a host is unreachable. ## This module's first admin page Everything before it was configured through the API, because nothing until now had to be **authored**. What is on the page is decided by what an operator can get wrong — four states that are invisible from the game and from a list of grants, each a sentence rather than a number: - a grant against somebody who has linked no Steam account (authored, stored, pushed nowhere); - a permission nothing has registered; - a member waiting on their first connection, named next to the person; - a server whose last sync failed, with the transport word that tells a wrong URL from a wrong token. The `admin.users.detail` slot phase 6 built gains the same thing for one person, which is where an operator actually asks "why can this player spawn a kit". ## Four defects the browser found that 133 green tests did not 1. **A person with permissions and no linked Steam account had the whole Rust section hidden** on core's user page — the section was gated on links alone, and that person is precisely the one an operator needs to see, because their grants reach nobody. 2. **A member waiting on a first connection looked like an ordinary member.** The count was in the server strip and nothing was beside the person. 3. **A grant naming an unregistered permission carried no warning**, though the group's permission list had carried one all along. 4. The page drew its own `<h1>` under core's chrome title — the same words twice. A fifth was found by a test rather than a browser, and it was the better catch: `buildDesired` resolved a grant's accounts from the **join** in `listGrants` rather than from the link map, which gives the right answer by accident until somebody changes that query and one of a person's two accounts quietly stops being granted. ## What was proven, and how A real core at the pinned ref with this module installed, the real `rust-link` sidecar, and a stand-in plugin speaking protocol 4 on the loopback — enough to walk everything the site owns: - a group created, its permission added and two memberships applied on the first sync; - `unresolved: ["kits.gold"]` and `pending: ["7656003:vip"]` reported and **kept out of** the pushed ledger; - a hand edit found as drift, **adopted** (and the refusal for one whose Steam id belongs to nobody: *"there is nobody to author this against"*), and **revoked** — queued, carried on the next sync, and cleared; - a withdrawn grant retired from the game (`pushed − desired`); - **a restart that emptied the store**: the loop saw `reason: "restart"`, re-pushed the whole set, and the group, its permission, both memberships and the grant came back — which is R2's central promise; - a `perm.drift` frame from the game marking the server dirty, and the sync after it reporting the hand edit authoritatively; - an unreachable second server reporting `transport-error` and retrying on its backoff while the first one stayed in sync. **Not proven, deliberately:** that a site grant gates a third-party plugin *in game*. That acceptance line needs a second, non-admin Steam account on the rig (PLAN.md §12.5) — the org lead's call to arrange — so it is written down as a walk to run rather than claimed. ## Checks 133 server tests, 39 client tests, `check:imports`, `check:bundle`, `check:swagger`, `check:externals` — all green. `swagger-fragment.json` (28 paths) and `routes.manifest.json` (30 routes, all documented) regenerated against a real core at the pinned ref. ## Base branch `edge`, per D18. ## AI disclosure - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
wtclaude added 2 commits 2026-09-21 23:29:34 +00:00
R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
docs(rust): record why a nested router needs nothing from the fragment generator
All checks were successful
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / server-tests (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 35s
f35e70e7d3
The opposite of the hole phase 6 found: the registration walk cannot see a
router mounted with `use()`, and swagger-autogen can — it reads a file and
follows its requires, so `/rust/permissions` is generated with the right prefix
from `rust.router.js` alone. Worth a comment where somebody will otherwise add
a fifth constant to make it work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
whitlocktech merged commit b1abd87c3d into edge 2026-09-22 06:53:22 +00:00
whitlocktech deleted branch feat/phase-7-permissions 2026-09-22 06:53:23 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-Rust#8
No description provided.