feat(rust): site-owned permissions — the site is the author, the game is the cache
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
This commit is contained in:
@@ -21,9 +21,10 @@ import { registry, coreApiVersion } from './core.js'
|
||||
import Servers from './routes/public/Servers.jsx'
|
||||
import ServerDetail from './routes/public/ServerDetail.jsx'
|
||||
import Account from './routes/player/Account.jsx'
|
||||
import Permissions from './routes/admin/Permissions.jsx'
|
||||
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||
import FooterStatus from './components/FooterStatus.jsx'
|
||||
import { IconLink } from './icons.jsx'
|
||||
import { IconKey, IconLink } from './icons.jsx'
|
||||
|
||||
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||
// and prefixes every route path with it.
|
||||
@@ -63,12 +64,25 @@ const ID = 'rust'
|
||||
// to do, and a landing page above one page is a page nobody wants. Core applies
|
||||
// its own portal chrome and its own auth gate to the tier, so the component
|
||||
// renders no layout and re-implements no check.
|
||||
//
|
||||
// **The admin route arrives in phase 7 and is this module's first.** Everything
|
||||
// before it was configured through the API — the server rows still are — because
|
||||
// nothing until now had to be AUTHORED. A permission model is different in kind:
|
||||
// it is a thing an operator composes and keeps looking at, and there is no
|
||||
// version of "grant somebody VIP" that belongs in a terminal.
|
||||
//
|
||||
// It is registered with an empty path, so it lands at `/admin/rust`, and core
|
||||
// applies the admin tier's own gate. The routes underneath it are stricter than
|
||||
// that gate (`requireRole('admin')` on every one), which is a server-side answer
|
||||
// rather than a client one: a moderator who reached this page would see it fail
|
||||
// honestly rather than be quietly shown a page that cannot save.
|
||||
registry.registerRoutes(ID, {
|
||||
public: [
|
||||
{ path: '', element: <Servers /> },
|
||||
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||
],
|
||||
player: [{ path: '', element: <Account /> }],
|
||||
admin: [{ path: '', element: <Permissions /> }],
|
||||
})
|
||||
|
||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||
@@ -105,6 +119,17 @@ registry.registerNav(ID, {
|
||||
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
|
||||
})
|
||||
|
||||
// The admin sidebar's row. `group` names an existing core group — an unknown name
|
||||
// appends a new group at the end rather than dropping the row, which is the
|
||||
// failure mode to avoid here: a row nobody can find is a feature nobody has.
|
||||
//
|
||||
// It carries an icon for the same reason the player row does: core draws one on
|
||||
// every sidebar row, and the one without is the only text in a column of glyphs.
|
||||
registry.registerNav(ID, {
|
||||
area: 'admin',
|
||||
items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }],
|
||||
})
|
||||
|
||||
// ── Extension slots ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core declares a slot, only core may declare one, and at most one module may
|
||||
|
||||
Reference in New Issue
Block a user