From 8df850f73e9bdd7e56c4766c8e95d582338bfc3f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 16 Sep 2026 21:55:17 -0500 Subject: [PATCH 01/51] feat: declare `rust` as the module's identity capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 is the Android app's leg of this module's read path (R10), and it gates its Rust navigation on one capability string the way `module-uo`'s five shard rows gate on `shard`. There was no such string here: the five this module declared all name a SURFACE, and core flattens every started module's capabilities into one list, so `servers` is a word another module could declare tomorrow and silently reveal these screens on a site that does not run Rust. `rust` is the string only this module can mean. It is asserted against `module.json`'s own `id` rather than a literal, so the two cannot drift. The README says why it is not redundant with `id`: `id` is a mount prefix, and MODULE_API.md §2.9 forbids a client inferring a route from a capability. Gating on `id` would quietly make those the same thing. Decided by the org lead as D16, 2026-09-16. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- README.md | 22 ++++++++++++++++++++++ module.json | 2 +- server/test/entry.test.js | 17 +++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c914029..439adfa 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,28 @@ events, the live map, Discord commands — arrives phase by phase. **Nothing is has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are both surfaces an operator can configure and then wait on, which is worse than an absent one. +### What a client feature-detects on + +`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any +client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10). +Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. + +The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is +not, for two reasons worth writing down before somebody tidies it away: + +- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every + started module's capabilities into one list, so `servers` alone is a word another module could + declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean + this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard` + does for `module-uo`. +- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the + directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must + never infer a route from a capability. Gating on `id` would quietly make the two the same thing, + and the day a client builds `//servers` from it, the contract that lets this module move its + own pages is gone. + +An unknown capability is absent, and no route is ever derived from one. + ## Build and check ```bash diff --git a/module.json b/module.json index b481b67..e71eb8b 100644 --- a/module.json +++ b/module.json @@ -12,5 +12,5 @@ "admin": ["/rust"], "player": ["/rust"] }, - "capabilities": ["servers", "killfeed", "leaderboard", "presence", "wipes"] + "capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes"] } diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 6711c99..0dd71ef 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -148,3 +148,20 @@ test('the module’s protocol version agrees with the manifest it ships beside', assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number') assert.ok(sidecar.PROTOCOL_VERSION >= 1) }) + +test('an identity capability is declared, and it is the module id (phase 5, D16)', () => { + // Core flattens every started module's capabilities into ONE list, so a client + // asking "is this module installed" needs a string only this module can + // declare. `servers` is not that string — it names a surface, and another + // module could name it too — which is the whole reason this one exists beside + // the five surface words. + // + // It is asserted against `manifest.id` rather than against the literal "rust" + // so that the two cannot drift: the day the id changes, the capability a + // client gates a whole navigation group on has to change with it. + assert.ok( + manifest.capabilities.includes(manifest.id), + `module.json must declare "${manifest.id}" as a capability — it is the only string a client can` + + ' use to tell this module apart from any other, and the Android app gates its Rust rows on it', + ) +}) -- 2.49.1 From baffaa46c9094a95f8e68489669d0298e63d4cf1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 08:18:48 -0500 Subject: [PATCH 02/51] =?UTF-8?q?feat(rust):=20identity=20=E2=80=94=20a=20?= =?UTF-8?q?link=20code=20from=20the=20game,=20and=20the=20Steam=20id=20ins?= =?UTF-8?q?ide=20core's=20user=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1's identity link, site-side, and R13's first extension slot. A player types /link in game, the plugin hands them a six-character code privately, and they enter it here; the site records who owns which Steam account, and an operator sees that on core's own `/admin/users/:id` page. **The site is the author of record and the game holds nothing.** There is no per-account store in Rust that survives a wipe, and phase 7 needs the site authoritative anyway — it pushes permissions INTO the game keyed by Steam id. A copy in the game would be a second thing to reconcile every wipe, for no question it could answer better. ## D24 — a code is minted by ONE server, so every server is asked Nothing in six characters says where it came from. The fleet is asked 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 actually holds it. Asking the player to pick was rejected: a wrong pick would come back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous. **"Every reachable server refused" is not the same answer as "a server was unreachable."** Collapsing them tells a player whose server is down that their code is wrong — so they run /link again on that same server and are told the same thing for as long as it stays down. `unsure` is that case, and it says to try again rather than to fetch a new code. ## D23 — a Steam id another account holds is refused, never moved The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a silent move is an account takeover performed by typing six characters. The refusal names the holder, because the advice is unusable without it. The INSERT is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE that move — and the duplicate-key error is the refusal for the race the check above cannot close. The way out is `/unlink` in game, which reaches the site off the ingest feed rather than through a route (the plugin has no link to delete). D25 adds the other way out: staff can sever a link from the admin panel, for a player who cannot reach that Steam account in game. ## The slot, and the hole it found in this repo's own generator `admin.users.detail` is declared in `module.json` AND registered in `index.js` AND filled by the chunk — three places, because the server half and the client half are different registrations that share one name. `swaggerFragment.js` knew only about tier routers, so the two routes under `/admin/users/:id` were generated by nothing: a fragment that was internally consistent and described two routes fewer than the module serves. A slot's mount is core's and cannot be derived here, so it is a fourth constant beside `TIER_BASE` — held to account by the frozen-manifest job, which was verified to catch exactly this by removing the two paths and watching it fail. ## Smaller things worth knowing - **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page re-reads after its own write; it blanks while it re-reads, which is right here and is exactly what made it wrong for a poll. - **Every player-portal nav row needs an `icon`** — core draws one on every row, and the client suite says so. This module had no icons file until now, because the public header is text buttons. - The two new frame kinds are STAFF-only. Neither carries a code, but both name a Steam id beside a website account's activity, and that join is not a public fact about what happened on a server. - The link code route carries its own rate limiter rather than core's `accountChangeLimiter`: this is guessing somebody else's secret, not changing your own password, and a shared counter would let one policy set the other. Protocol 3 on all three declaration sites; 17 new tests, 136 green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- client/src/api.js | 31 +- client/src/entry.jsx | 32 + client/src/icons.jsx | 49 ++ client/src/routes/admin/UserRustSections.jsx | 147 ++++ client/src/routes/player/Account.jsx | 191 +++++ module.json | 3 +- routes.manifest.json | 25 + server/catalogue.js | 9 +- server/db/purge.sql | 1 + server/db/schema.sql | 44 + server/index.js | 26 +- server/ingest.js | 29 + server/model/links/links.db.js | 147 ++++ server/model/links/links.model.js | 247 ++++++ server/router/admin/usersRust.controller.js | 71 ++ server/router/admin/usersRust.router.js | 73 ++ server/router/player/rust.controller.js | 123 ++- server/router/player/rust.router.js | 95 ++- server/scripts/swaggerFragment.js | 25 + server/sidecarClient.js | 36 +- server/swagger/doc.js | 95 +++ server/test/catalogue.test.js | 15 +- server/test/identityRoutes.test.js | 82 ++ server/test/ingest.test.js | 32 + server/test/links.test.js | 276 +++++++ swagger-fragment.json | 795 +++++++++++++++++++ 26 files changed, 2664 insertions(+), 35 deletions(-) create mode 100644 client/src/icons.jsx create mode 100644 client/src/routes/admin/UserRustSections.jsx create mode 100644 client/src/routes/player/Account.jsx create mode 100644 server/model/links/links.db.js create mode 100644 server/model/links/links.model.js create mode 100644 server/router/admin/usersRust.controller.js create mode 100644 server/router/admin/usersRust.router.js create mode 100644 server/test/identityRoutes.test.js create mode 100644 server/test/links.test.js diff --git a/client/src/api.js b/client/src/api.js index e435da6..429c8b1 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -74,6 +74,20 @@ export const playerServers = { list: () => req('/player/rust/servers'), } +// R1's identity link, from the signed-in player's side. +// +// **The code is the whole of what goes up.** The site has no idea which server +// minted it — nothing in six characters says — so the server half asks each +// configured server in turn (D24). A page that asked the player to pick would be +// asking them a question the site can answer itself, and a wrong pick would come +// back indistinguishable from a wrong code. +export const playerLinks = { + list: () => req('/player/rust/links'), + confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }), + remove: (steamId) => + req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }), +} + // ── admin ───────────────────────────────────────────────────────────────── // **`sidecarToken` goes up and never comes back.** The list answers `hasToken`, // and a save that omits the field leaves the stored credential alone — so an @@ -89,8 +103,23 @@ export const admin = { req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }), } +// ── the admin.users.detail extension slot ───────────────────────────────── +// +// The client half of R13's first slot. Core hands the component a `userId` and +// NOTHING else — not a client — so an extension builds its own bindings for the +// routes it registered at the other end (§3.5). These two are the only calls in +// this file whose path is core's rather than this module's: the resource is +// core's user, and the module's own segment is the part after it. +export const adminUserLinks = { + list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`), + remove: (userId, steamId) => + req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, { + method: 'DELETE', + }), +} + // Exported for the rare caller that needs the base itself — an ``, a // download link, an EventSource. Reach for `request` first. export { BASE, query } -export default { servers, playerServers, admin, BASE } +export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE } diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 9177e22..7fb8b18 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -20,7 +20,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 UserRustSections from './routes/admin/UserRustSections.jsx' import FooterStatus from './components/FooterStatus.jsx' +import { 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. @@ -54,11 +57,18 @@ const ID = 'rust' // // React Router ranks a static segment above a dynamic one, so `/rust` wins // against core's `/:slug` CMS route without depending on registration order. +// +// The player route is registered with an empty path for the same reason the +// public list is: `/player/rust` is the whole of what this module asks a player +// 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. registry.registerRoutes(ID, { public: [ { path: '', element: }, { path: 'servers/:id', element: }, ], + player: [{ path: '', element: }], }) // ── Nav ─────────────────────────────────────────────────────────────────── @@ -83,6 +93,18 @@ registry.registerNav(ID, { items: [{ label: 'Servers', to: '/rust' }], }) +// The player portal's row. It carries an `icon` because core draws one on every +// portal row — a row without one is the only text in a column of glyphs, and +// core used to render `` unguarded, which blanked the whole portal. +// +// No `order`: an unordered row appends after core's own rather than claiming a +// position it was not given. Account, appeals and notifications are what a player +// came to the portal for; linking a game account is what they do once. +registry.registerNav(ID, { + area: 'player', + items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }], +}) + // ── Extension slots ─────────────────────────────────────────────────────── // // Core declares a slot, only core may declare one, and at most one module may @@ -97,6 +119,16 @@ registry.registerNav(ID, { // purpose. registry.registerExtension(ID, 'site.footer.status', FooterStatus) +// R13's other slot, and the one that IS named in `module.json` — because it has +// a server half too (`server/router/admin/usersRust.router.js`). The two halves +// carry one name on purpose: a module that adds routes under +// `/api/v1/admin/users/:id` is the module with something to show on that page. +// +// Core passes `userId` and nothing else, so the component builds its own client +// for the routes the server half registered. It renders NOTHING for a user with +// no linked Steam account, which is most of them. +registry.registerExtension(ID, 'admin.users.detail', UserRustSections) + // `module.json`'s `coreApi` range was checked by the loader before this file was // ever served, so there is nothing to re-check here. Log it anyway: a mismatch // between the core that validated the manifest and the core that published this diff --git a/client/src/icons.jsx b/client/src/icons.jsx new file mode 100644 index 0000000..941eb3e --- /dev/null +++ b/client/src/icons.jsx @@ -0,0 +1,49 @@ +// ── The nav glyph for this module's player-portal row ───────────────────── +// +// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core +// renders whatever component a row carries, exactly as it renders its own rows' +// icons — and core's player portal draws a glyph on every row, so a row without +// one reads as breakage rather than as a design. The client suite asserts it. +// +// The public header is text buttons and carries no icons, which is why this file +// arrives with the player row and not before it. +// +// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by +// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes +// of presentation are not a component: putting them in the shared kit would +// freeze core's icon sizing into the contract, where changing it later would be a +// major bump. A module that wants to look like the nav it is in matches that nav. + +const Icon = ({ children }) => ( + +) + +/** + * A chain link — what the row is for. + * + * Not a gem, a person or a server: the portal's rows say what a player does + * there, and what a player does at `/player/rust` is link an account. Core's own + * neighbours are a gear (account), a shield (appeals) and a bell (notifications), + * so the row has to read as a verb in that company. + */ +export const IconLink = () => ( + + + + +) + +export default { IconLink } diff --git a/client/src/routes/admin/UserRustSections.jsx b/client/src/routes/admin/UserRustSections.jsx new file mode 100644 index 0000000..3313965 --- /dev/null +++ b/client/src/routes/admin/UserRustSections.jsx @@ -0,0 +1,147 @@ +// ── This module's fill for `admin.users.detail` ─────────────────────────── +// +// R13's first slot, and the phase criterion as an operator meets it: the Steam +// id inside core's own user page, under core's own security panel. +// +// **The slot hands over `userId` and nothing else** — not a client. So this file +// builds its own bindings for the routes the server half registered +// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of +// a call belong to the same module even when the URL between them is core's. +// +// **Most users have no Rust account, so most of the time this renders nothing.** +// A panel that announced "no linked Steam accounts" on every user page in a +// community that also runs a UO shard would be noise on the overwhelming +// majority of them. Silence is the honest answer to "what does the Rust module +// know about this person" when it is nothing. + +import { useCallback, useState } from 'react' +import { ago, count, duration } from '../../lib/format.js' +import { useAsync } from '../../core.js' +import api from '../../api.js' + +/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */ +function SectionTitle({ children }) { + return ( +
+ {children} +
+ ) +} + +/** One server's all-time totals for this player. */ +function ServerRow({ server }) { + return ( +
  • + {server.serverName} + + {count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)} + {server.wipes > 1 ? ` · ${server.wipes} wipes` : ''} + +
  • + ) +} + +/** One linked Steam account: who it is, when it was linked, and the way out. */ +function LinkPanel({ userId, link, onRemoved }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + async function unlink() { + setBusy(true) + setError('') + try { + await api.adminUserLinks.remove(userId, link.steamId) + await onRemoved() + } catch (err) { + setError(err.message || 'Could not unlink that account.') + setBusy(false) + } + } + + return ( +
    +
    +
    +
    + {link.name || link.steamId} +
    +
    + {link.steamId} · linked {ago(link.linkedAt)} + {link.serverId ? ` on ${link.serverId}` : ''} + {link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'} +
    + {/* Worth showing only when they differ: the name on the link is what + they were called when they linked, the other is what the game last + saw. A rename is the ordinary reason, and an operator reading a + support ticket wants both names. */} + {link.linkedName && link.name && link.linkedName !== link.name && ( +
    + Linked as “{link.linkedName}”. +
    + )} +
    + +
    + + {error && ( +

    {error}

    + )} + + {link.servers.length > 0 && ( +
      + {link.servers.map((server) => ( + + ))} +
    + )} +
    + ) +} + +export default function UserRustSections({ userId }) { + // Core's `useAsync` has no refresh, so a counter in the deps is how this + // re-reads after its own write (the same shape the player page uses). + const [reloads, setReloads] = useState(0) + const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads]) + const reload = useCallback(() => setReloads((n) => n + 1), []) + + // No `Loading` and no `ErrorState`, deliberately. This is a section inside + // somebody else's page: a spinner on every user page for a module most users + // have nothing to do with is worse than a section that appears when it has + // something, and a failure here must not replace core's own user detail with an + // error card. + if (!data || data.links.length === 0) return null + + return ( +
    + Rust + +
    + {data.links.map((link) => ( + + ))} +
    + +

    + A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is + recorded in the activity log — it is the way back for a player who linked the wrong account + and cannot reach it in game. +

    +
    + ) +} diff --git a/client/src/routes/player/Account.jsx b/client/src/routes/player/Account.jsx new file mode 100644 index 0000000..b498c4d --- /dev/null +++ b/client/src/routes/player/Account.jsx @@ -0,0 +1,191 @@ +// ── The player's own Rust identity ──────────────────────────────────────── +// +// `/player/rust` — where a signed-in player links the Steam account they play +// on. It is the one page in this module a player is asked to *do* something on, +// and the thing they are doing matters more than it looks: from phase 7 the link +// is what in-game permissions are granted against, and from phase 13 it is what +// rewards are handed to. +// +// **A player route renders no layout of its own.** Core wraps `/player/*` in its +// own portal chrome, so this page starts at a heading — unlike the public pages +// in this module, which render `PublicLayout` themselves. +// +// The three-step instruction at the top is not decoration. Nothing else on the +// site tells a player that the code comes from the game, and a code field with no +// explanation is a code field nobody can use. + +import { useCallback, useState } from 'react' +import { ErrorState, Loading, useAsync } from '../../core.js' +import { ago, shortId } from '../../lib/format.js' +import api from '../../api.js' + +/** The code field, and the four answers it can produce. */ +function LinkForm({ onLinked }) { + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + + async function submit(event) { + event.preventDefault() + if (!code.trim() || busy) return + + setBusy(true) + setMessage('') + setError('') + + try { + const result = await api.playerLinks.confirm(code.trim()) + setMessage( + result.already + ? 'That account was already linked to you.' + : `Linked ${result.link.name || shortId(result.link.steamId)}.`, + ) + setCode('') + await onLinked() + } catch (err) { + // Every refusal the server sends is already a sentence aimed at a player — + // "run /link again", "run /unlink in game", "try again in a minute" — so + // this renders it rather than replacing it with one of its own. The three + // are not interchangeable, and a page that flattened them into "could not + // link that code" would send a player back to the server that is down. + setError(err.message || 'Could not link that code.') + } finally { + setBusy(false) + } + } + + return ( +
    +
    + + +
    + + {message && ( +

    {message}

    + )} + {error && ( +

    {error}

    + )} +
    + ) +} + +/** One linked account, and the control that releases it. */ +function LinkRow({ link, onRemoved }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + async function remove() { + setBusy(true) + setError('') + try { + await api.playerLinks.remove(link.steamId) + await onRemoved() + } catch (err) { + setError(err.message || 'Could not unlink that account.') + setBusy(false) + } + } + + return ( +
  • +
    +
    + {link.name || shortId(link.steamId)} +
    +
    + {link.steamId} · linked {ago(link.linkedAt)} + {link.serverId ? ` on ${link.serverId}` : ''} +
    + {error && ( +

    {error}

    + )} +
    + +
  • + ) +} + +export default function Account() { + // `useAsync` rather than this module's `usePolled`: nothing here changes unless + // the person looking at it changes it, and a page that re-asked every twenty + // seconds would be asking a question nobody is waiting on. + // + // **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and + // that is the whole of its interface — so a counter in the deps is how a page + // re-reads after its own write. It blanks while it re-reads, which is right + // here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`). + const [reloads, setReloads] = useState(0) + const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads]) + const links = data ? data.links : [] + + const reload = useCallback(() => setReloads((n) => n + 1), []) + + return ( +
    +
    Steam accounts
    + +

    + Linking tells this site which Steam account is yours, so your play on our servers appears + under your name here — and so rewards and permissions the site hands out can reach you in + game. +

    + +
      +
    1. Join any of our Rust servers and type /link in chat.
    2. +
    3. The server replies with a six-character code, only you can see it, and it lasts five minutes.
    4. +
    5. Type it below. It works once.
    6. +
    + + + + {loading && } + {error && } + + {data && links.length > 0 && ( +
      + {links.map((link) => ( + + ))} +
    + )} + + {data && links.length > 0 && ( +

    + A link covers every server this community runs — a Steam account is one person wherever + they play, while stats are kept per server and per wipe. You can also type + {' '}/unlink in game to release one. +

    + )} + + {data && links.length === 0 && ( +

    + No Steam account is linked to this profile yet. +

    + )} +
    + ) +} diff --git a/module.json b/module.json index e71eb8b..0157d16 100644 --- a/module.json +++ b/module.json @@ -12,5 +12,6 @@ "admin": ["/rust"], "player": ["/rust"] }, - "capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes"] + "extensions": ["admin.users.detail"], + "capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"] } diff --git a/routes.manifest.json b/routes.manifest.json index 97906d9..18657bd 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -6,11 +6,31 @@ "path": "/api/v1/admin/rust/servers/:id", "tier": "public" }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/rust/links/:steamId", + "tier": "public" + }, + { + "method": "DELETE", + "path": "/api/v1/player/rust/links/:steamId", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/admin/rust/servers", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/rust/links", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/rust/links", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/player/rust/servers", @@ -51,6 +71,11 @@ "path": "/api/v1/admin/rust/servers/:id/test", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/player/rust/link", + "tier": "public" + }, { "method": "PUT", "path": "/api/v1/admin/rust/servers/:id", diff --git a/server/catalogue.js b/server/catalogue.js index 90a3d4e..cf255bf 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -69,9 +69,16 @@ const STAFF_KINDS = Object.freeze([ 'player.unbanned', 'player.login.attempt', 'player.approved', + // Protocol 3's two account frames. Neither carries a code — the code travels + // through the player, which is what makes typing it proof — but both name a + // Steam id ALONGSIDE a website account's activity, which is exactly the join a + // public page must not be able to make: "this player is that person" is a fact + // about somebody's identity, not about what happened on the server. + 'account.link.requested', + 'account.unlinked', ]) -/** Every kind protocol 2 defines. */ +/** Every kind protocol 3 defines. */ const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS]) const PUBLIC = new Set(PUBLIC_KINDS) diff --git a/server/db/purge.sql b/server/db/purge.sql index e179986..bb0e96d 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -19,6 +19,7 @@ -- it knows this module registered, because it is the side that knows which -- registrant owned what. +DROP TABLE IF EXISTS rust_account_links; DROP TABLE IF EXISTS rust_ingest_cursor; DROP TABLE IF EXISTS rust_presence; DROP TABLE IF EXISTS rust_events; diff --git a/server/db/schema.sql b/server/db/schema.sql index d32fbdb..3248d5f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -291,6 +291,50 @@ CREATE TABLE IF NOT EXISTS rust_ingest_cursor ( ); +-- ── Who owns which Steam account ────────────────────────────────────────── +-- +-- R1's identity link, and the reason it is a table rather than a column on +-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed +-- by a Steam id, and it outlives every row this module writes about play. A +-- column here would be null for the overwhelming majority of players and would +-- be deleted by any sweep that pruned inactive ones. +-- +-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that +-- call in protocol 2 and it is the truth of the thing: a Steam account is one +-- person across every server an operator runs, where stats are per server and +-- per wipe. Linking on one server links for the fleet, because there is nothing +-- else it could honestly mean. +-- +-- **One Steam id, at most one user** — that is what the primary key buys, and it +-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may +-- do what in game and phase 13 makes it the thing that hands out loot; both are +-- grants against a Steam id, and both assume the question "whose is this?" has +-- exactly one answer. +-- +-- The reverse is deliberately NOT constrained: one website user may hold several +-- Steam accounts. People have a second account, or a family shares a site login, +-- and refusing that would be inventing a rule the game does not have. +-- +-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The +-- alternative is a row naming a user id that resolves to nobody, which every +-- read would then have to defend against. +CREATE TABLE IF NOT EXISTS rust_account_links ( + steam_id VARCHAR(32) NOT NULL PRIMARY KEY, + user_id INT NOT NULL, + -- What the player was called in game when they linked. A display name, kept + -- so an operator reading the admin panel sees a person rather than a number; + -- never used to identify anybody, because a Rust name changes on a whim. + name VARCHAR(191) NULL, + -- Which server minted the code. Not part of the identity — the link is + -- fleet-wide — but an operator asking "where did this come from" has no other + -- way to find out, and a support conversation starts there. + server_id VARCHAR(64) NULL, + linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + KEY idx_rust_links_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + -- ── Changes to tables that already shipped ──────────────────────────────── -- -- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS` diff --git a/server/index.js b/server/index.js index 9692d31..80ebf2b 100644 --- a/server/index.js +++ b/server/index.js @@ -50,6 +50,7 @@ module.exports = function register(ctx, api) { const publicRust = require('./router/public/rust.router') const playerRust = require('./router/player/rust.router') const adminRust = require('./router/admin/rust.router') + const usersRust = require('./router/admin/usersRust.router') const boot = require('./boot') /* eslint-enable global-require */ @@ -78,6 +79,20 @@ module.exports = function register(ctx, api) { admin: { '/rust': adminRust }, }) + // R13's first extension slot (§2.4). Core declares `admin.users.detail` on + // `/api/v1/admin/users/:id` and we fill it; the router receives the parent's + // `req.params.id` through `mergeParams`. Core's own routes on the resource are + // declared before the slot is mounted, so core wins any path conflict — it owns + // the user, and this module owns what it can say about one. + // + // **It is declared twice, in two different places, on purpose.** This call is + // the SERVER half and `module.json`'s `extensions` array is held against it by + // the loader. The CLIENT half is `registry.registerExtension(ID, + // 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array — + // phase 1 found that the hard way with `site.footer.status`, which is a client + // slot and fails the load outright when named there. + api.registerExtension('admin.users.detail', usersRust) + // The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this // module's schema fragment, and BEFORE the HTTP listener binds — so a module // that must not serve traffic until it has warmed a cache gets that for free. @@ -92,14 +107,15 @@ module.exports = function register(ctx, api) { // Everything else this module will register — the Team provider, the event // triggers and audiences, the engagement seeds, the four event catalogues, the - // notification streams, the slash commands and the two extension slots — is - // deliberately absent. Each arrives with the phase that has something real to - // put in it. A registration with nothing behind it is worse than a missing one: - // a declared trigger nothing emits and a declared slot nothing fills are both - // surfaces an operator can configure and then wait on. + // notification streams and the slash commands — is deliberately absent. Each + // arrives with the phase that has something real to put in it. A registration + // with nothing behind it is worse than a missing one: a declared trigger + // nothing emits and a declared slot nothing fills are both surfaces an operator + // can configure and then wait on. log.info('registered', { version: require('../module.json').version, routes: 'public:/rust player:/rust admin:/rust', + extensions: 'admin.users.detail', }) } diff --git a/server/ingest.js b/server/ingest.js index 3aada97..7e878d5 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -34,6 +34,7 @@ const core = require('./core') const db = require('./model/events/events.db') +const links = require('./model/links/links.model') const sidecar = require('./sidecarClient') const log = core.logger('ingest') @@ -144,6 +145,34 @@ async function apply(serverId, item) { await db.touchPlayer(frame.steamId, frame.name || null) break + // ── Protocol 3: the one frame that changes something other than a counter ── + // + // `/unlink` in game severs the site's link, and it is the only way out of a + // link on the wrong account: the site REFUSES to move a Steam id another + // website account already holds (D23), so without this a player who linked + // while signed in as the wrong account would need staff. + // + // It arrives here rather than through a route because the plugin has nothing + // to delete — the site is the author of record and the game holds no link — + // so `/unlink` is the game reporting what the player asked for, applied off + // the feed like every other frame. + // + // **The authority is the Steam account itself.** Whoever is connected to the + // game as it is who it is, which is a stronger proof of ownership than the + // site can obtain any other way, so this is not scoped by website user. + case 'account.unlinked': + await db.touchPlayer(frame.steamId, frame.name || null) + await links.unlinkFromGame(frame.steamId) + break + + // Stored and counted as a sighting, nothing more. The code is deliberately + // NOT on this frame — it travels through the player — so there is nothing + // here to redeem and no pending state for the site to hold. It exists so an + // operator can see linking being used at all. + case 'account.link.requested': + await db.touchPlayer(frame.steamId, frame.name || null) + break + default: // Stored, not counted. Moderation frames, the server lifecycle, and // anything a newer protocol sends that this build does not understand. diff --git a/server/model/links/links.db.js b/server/model/links/links.db.js new file mode 100644 index 0000000..295bb36 --- /dev/null +++ b/server/model/links/links.db.js @@ -0,0 +1,147 @@ +// ── SQL, and nothing else ───────────────────────────────────────────────── +// +// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its +// keep). Raw parameterised SQL through `core.query`, placeholders always. + +const core = require('../../core') + +const LINKS = 'rust_account_links' +const PLAYERS = 'rust_players' +const STATS = 'rust_player_wipe_stats' + +/** + * The link for one Steam id, or undefined. + * + * Joins core's `users` for the username, because every caller that asks "who + * owns this?" wants a name rather than an integer — and the one caller that + * refuses a re-link has to be able to say *whose* it is. + */ +async function getBySteamId(steamId) { + const rows = await core.query( + `SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId, + l.linked_at AS linkedAt, u.username + FROM ${LINKS} l + JOIN users u ON u.id = l.user_id + WHERE l.steam_id = ?`, + [steamId], + ) + return rows[0] +} + +/** Every Steam account one website user holds, newest first. */ +async function listForUser(userId) { + return core.query( + `SELECT steam_id AS steamId, user_id AS userId, name, server_id AS serverId, + linked_at AS linkedAt + FROM ${LINKS} + WHERE user_id = ? + ORDER BY linked_at DESC`, + [userId], + ) +} + +/** + * Record a link. + * + * **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in + * SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one + * website account to another — which, once phase 7 makes a link a privilege path + * and phase 13 makes it an entitlement, is an account takeover performed by + * typing a six-character code. The duplicate-key error is the refusal, and the + * controller turns it into a sentence. + */ +async function insert({ steamId, userId, name, serverId }) { + await core.query( + `INSERT INTO ${LINKS} (steam_id, user_id, name, server_id) + VALUES (?, ?, ?, ?)`, + [steamId, userId, name || null, serverId || null], + ) +} + +/** + * Remove a link the caller owns. + * + * Scoped by `user_id` in the statement rather than checked before it: a delete + * that reads, decides, then writes has a gap between the read and the write, and + * this way the ownership test and the deletion are the same operation. Answers + * how many rows went, so a caller can tell "removed" from "was not yours". + */ +async function removeOwned(steamId, userId) { + const result = await core.query( + `DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`, + [steamId, userId], + ) + return Number(result && result.affectedRows) || 0 +} + +/** + * Remove a link whoever holds it — the in-game `/unlink` path, and the staff + * unlink on the `admin.users.detail` panel (D25). + * + * Unscoped by user on purpose: neither caller is the link's owner and both have + * already established their authority another way. In game the authority is the + * Steam account itself — whoever is connected as it is who it is; on the admin + * panel it is the tier gate. Which is why the admin caller writes an + * `activity.log` entry naming the operator and this does not: it cannot tell the + * two apart, and a log line that guessed would be worse than none. + */ +async function removeBySteamId(steamId) { + const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId]) + return Number(result && result.affectedRows) || 0 +} + +/** + * Every link one user holds, enriched with what this module knows about that + * player — for the `admin.users.detail` panel. + * + * A LEFT JOIN, because a player can link an account and never play on it. An + * operator looking at that user should see the link, not an empty panel. + */ +async function listForUserWithPlayer(userId) { + return core.query( + `SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt, + p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen + FROM ${LINKS} l + LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id + WHERE l.user_id = ? + ORDER BY l.linked_at DESC`, + [userId], + ) +} + +/** + * Per-server all-time totals for one Steam id. + * + * The same rows the public leaderboard sums, grouped by server instead of + * filtered to one — so an operator sees a player across the fleet in one read. + * All-time, deliberately: an admin looking at a user wants their history, not + * this week's. + */ +async function statsForSteamId(steamId) { + return core.query( + `SELECT s.server_id AS serverId, srv.name AS serverName, + SUM(s.kills) AS kills, + SUM(s.deaths) AS deaths, + SUM(s.npc_kills) AS npcKills, + SUM(s.structures) AS structures, + SUM(s.playtime_sec) AS playtimeSec, + MAX(s.last_seen) AS lastSeen, + COUNT(DISTINCT s.wipe_id) AS wipes + FROM ${STATS} s + LEFT JOIN rust_servers srv ON srv.id = s.server_id + WHERE s.steam_id = ? + GROUP BY s.server_id, srv.name + ORDER BY SUM(s.playtime_sec) DESC`, + [steamId], + ) +} + +module.exports = { + getBySteamId, + listForUser, + listForUserWithPlayer, + insert, + removeOwned, + removeBySteamId, + statsForSteamId, +} diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js new file mode 100644 index 0000000..9962cab --- /dev/null +++ b/server/model/links/links.model.js @@ -0,0 +1,247 @@ +// ── Who owns which Steam account ────────────────────────────────────────── +// +// R1's identity link, site-side. The flow it sits in the middle of: +// +// 1. In game, a player types `/link`. The plugin mints a one-time code, tells +// them privately, and holds it in memory for five minutes. +// 2. On the website, the player types that code. This module asks the sidecar, +// which asks the plugin, which answers with the Steam id the code belongs +// to and drops it. +// 3. This file records the result. +// +// **The site is the author of record and the game holds nothing.** That is the +// one real difference from the UO bridge, which writes a tag onto the game +// account: there is no equivalent per-account store in Rust that survives a wipe, +// and phase 7 needs the site to be authoritative anyway — it pushes permissions +// INTO the game keyed by Steam id. A copy in the game would be a second thing to +// reconcile every wipe, for no question it could answer better. + +const core = require('../../core') +const db = require('./links.db') +const servers = require('../servers/servers.model') +const sidecar = require('../../sidecarClient') + +const log = core.logger('links') + +/** What a link looks like to any caller. Never carries a raw code. */ +function shape(row) { + if (!row) return null + return { + steamId: row.steamId, + name: row.name || null, + serverId: row.serverId || null, + linkedAt: row.linkedAt, + } +} + +/** The Steam accounts one website user holds. */ +async function listForUser(userId) { + return (await db.listForUser(userId)).map(shape) +} + +/** True when this user holds this Steam id. The ownership gate every player read uses. */ +async function owns(steamId, userId) { + const row = await db.getBySteamId(steamId) + return Boolean(row && Number(row.userId) === Number(userId)) +} + +/** + * Redeem a code against one server, and record the link. + * + * Answers a discriminated result rather than throwing, because every outcome + * here is a sentence somebody has to read: + * + * `{ ok: true, link }` — linked + * `{ ok: false, reason: 'rejected' }`— the game says that code is not good + * `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id + * `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer + * + * **`rejected` deliberately collapses "unknown" and "expired".** The plugin + * distinguishes them and an operator reading its log can too; a stranger typing + * codes must not learn which of the two they hit, because that is the difference + * between "keep guessing" and "guess faster". + */ +async function confirmOne({ server, code, userId }) { + const result = await sidecar.confirmLink(server, code) + + // The transport failed: the sidecar is unreachable, the game is not connected, + // or the reply never came. None of those is a verdict on the code, so the + // player is told to try again rather than that their code is wrong. + if (!result.ok) { + log.warn('link confirm did not reach the game', { server: server.id, status: result.status }) + return { ok: false, reason: 'offline' } + } + + const frame = result.data || {} + + // The plugin's own refusal. `frame.reason` is `unknown`, `expired` or + // `malformed`; it is logged and not surfaced (see the doc above). + if (frame.kind !== 'link.ok' || !frame.steamId) { + log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' }) + return { ok: false, reason: 'rejected' } + } + + const steamId = String(frame.steamId) + const held = await db.getBySteamId(steamId) + + // D23: refuse, and say whose it is. A move would transfer every permission and + // entitlement phases 7 and 13 hang off this link, on a code anybody in game + // could have run — and the player's way out is `/unlink` in game, which they + // can reach from the machine they are sitting at. + if (held) { + if (Number(held.userId) === Number(userId)) { + // Already theirs. Not an error: a player who pressed the button twice, or + // one whose code was confirmed on a request that then timed out. + return { ok: true, link: shape(held), already: true } + } + return { ok: false, reason: 'taken', username: held.username } + } + + try { + await db.insert({ + steamId, + userId, + name: frame.name || null, + serverId: server.id, + }) + } catch (err) { + // The race the PRIMARY KEY exists for: two confirmations of the same Steam + // id, interleaved between the check above and this write. The key refuses the + // second and it becomes the same refusal, rather than a 500. + if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) { + const now = await db.getBySteamId(steamId) + if (now && Number(now.userId) === Number(userId)) { + return { ok: true, link: shape(now), already: true } + } + return { ok: false, reason: 'taken', username: now && now.username } + } + throw err + } + + const link = shape(await db.getBySteamId(steamId)) + log.info('steam account linked', { steamId, userId, server: server.id }) + return { ok: true, link } +} + +/** + * Redeem a code against the fleet (D24). + * + * **A code is minted by ONE server and the player types six characters into a + * browser**, so the site cannot know which server it came from — nothing in the + * code says, and asking the player to pick would make a wrong guess + * indistinguishable from a wrong code, which is the one refusal that must not be + * ambiguous. So every enabled server is asked in turn and the first `link.ok` + * wins. The others answer `unknown` and nothing happens there: a code is only + * spent at the server that actually holds it. + * + * The loop stops early on `taken`, because that is a verdict about the Steam id + * rather than about this server — asking the rest of the fleet would produce the + * same answer more slowly. + * + * **"Every reachable server refused" is not the same answer as "a server was + * unreachable"**, and collapsing them is how a player who linked on the one + * server that is down gets told their code is wrong. `unsure` is that case, and + * the sentence it earns says to try again rather than to run `/link` again. + */ +async function redeem({ code, userId }) { + const fleet = await servers.listForPolling() + + if (fleet.length === 0) return { ok: false, reason: 'no-servers' } + + let refused = 0 + let unreachable = 0 + + for (const server of fleet) { + // Sequential, deliberately. In parallel every server would be asked even + // after one had already answered, and a code spent on the right server would + // still be travelling to five others — for a fleet of six and a five-minute + // TTL, there is nothing to win by racing them. + // eslint-disable-next-line no-await-in-loop + const result = await confirmOne({ server, code, userId }) + + if (result.ok || result.reason === 'taken') return result + + if (result.reason === 'offline') unreachable += 1 + else refused += 1 + } + + if (refused === 0) return { ok: false, reason: 'offline' } + if (unreachable > 0) return { ok: false, reason: 'unsure' } + + return { ok: false, reason: 'rejected' } +} + +/** Remove a link the caller owns. False when they did not hold it. */ +async function unlinkOwned(steamId, userId) { + return (await db.removeOwned(steamId, userId)) > 0 +} + +/** + * Remove a link whoever holds it. + * + * Two callers, both of which have already established their authority and + * neither of which is the link's owner: ingest applying an in-game `/unlink` + * (the authority is the Steam account — whoever is connected as it is who it + * is), and a staff unlink from the `admin.users.detail` panel (D25). + * + * It logs nothing about who asked, because the two callers record that + * differently: the admin one writes an `activity.log` entry naming the operator, + * and the game one has no operator to name. + */ +async function unlinkAnyOwner(steamId) { + return (await db.removeBySteamId(steamId)) > 0 +} + +/** + * Remove a link because the player asked in game. + * + * Called from ingest, off an `account.unlinked` event. + */ +async function unlinkFromGame(steamId) { + const removed = await unlinkAnyOwner(steamId) + if (removed) log.info('steam account unlinked in game', { steamId }) + return removed +} + +/** The admin panel's read: every link this user holds, with per-server totals. */ +async function forAdmin(userId) { + const links = await db.listForUserWithPlayer(userId) + + return Promise.all( + links.map(async (row) => ({ + steamId: row.steamId, + // The name on the LINK is what they were called when they linked; the one + // on `rust_players` is what the game last saw. They differ the moment + // somebody renames, and the newer one is the useful one to show. + name: row.playerName || row.name || null, + linkedName: row.name || null, + serverId: row.serverId || null, + linkedAt: row.linkedAt, + firstSeen: row.firstSeen || null, + lastSeen: row.lastSeen || null, + servers: (await db.statsForSteamId(row.steamId)).map((s) => ({ + serverId: s.serverId, + serverName: s.serverName || s.serverId, + kills: Number(s.kills) || 0, + deaths: Number(s.deaths) || 0, + npcKills: Number(s.npcKills) || 0, + structures: Number(s.structures) || 0, + playtimeSec: Number(s.playtimeSec) || 0, + wipes: Number(s.wipes) || 0, + lastSeen: s.lastSeen || null, + })), + })), + ) +} + +module.exports = { + shape, + listForUser, + owns, + confirmOne, + redeem, + unlinkOwned, + unlinkAnyOwner, + unlinkFromGame, + forAdmin, +} diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js new file mode 100644 index 0000000..f26590d --- /dev/null +++ b/server/router/admin/usersRust.controller.js @@ -0,0 +1,71 @@ +// ── The `admin.users.detail` slot's handlers ────────────────────────────── +// +// What an operator can see and do about one website user's Rust identity. The +// user id is the PARENT's — `req.params.id` off core's `/admin/users/:id` — and +// every statement here is scoped by it, so a panel opened on one user cannot +// read or write another's rows by editing a path segment. + +const core = require('../../core') + +const links = require('../../model/links/links.model') + +const log = core.logger('admin') + +/** + * GET /admin/users/:id/rust/links + * + * The linked Steam accounts and, per server, what this module knows about the + * player behind them — all-time rather than this wipe's, because an operator + * looking at a user wants their history and the public leaderboard already + * answers the other question. + * + * **An empty array is an answer.** Most users have no Rust link at all, and the + * panel renders nothing rather than an error for them. + */ +async function listLinks(req, res) { + try { + res.json({ links: await links.forAdmin(req.params.id) }) + } catch (err) { + log.error('failed to read a user’s Rust links', { error: err.message }) + res.status(500).json({ error: 'Failed to read this user’s Rust accounts' }) + } +} + +/** + * DELETE /admin/users/:id/rust/links/:steamId — staff sever a link (D25). + * + * **This is the counterweight to D23.** The site refuses to move a Steam id that + * another website account already holds, and the player's own way out is + * `/unlink` in game — which is no way out at all for somebody who has lost access + * to that Steam account, or to the site account holding it. Staff are that route. + * + * Scoped by the parent user id in the statement rather than checked first: the + * ownership test and the deletion are one operation, and a link that belongs to a + * different user answers 404 from the page it was not on. + */ +async function removeLink(req, res) { + const { steamId } = req.params + const userId = req.params.id + + try { + const removed = await links.unlinkOwned(steamId, userId) + + if (!removed) return res.status(404).json({ error: 'That account is not linked to this user' }) + + // The one write this panel has, so it is the one thing here worth an audit + // row: after phase 7 a link is what permissions are granted against, and + // "who severed it" stops being a curiosity. + await core.activity.log({ + req, + action: 'rust.account.unlink.staff', + detail: { steamId, userId: Number(userId) }, + }) + + return res.json({ unlinked: true }) + } catch (err) { + log.error('failed to unlink a Steam account', { error: err.message }) + return res.status(500).json({ error: 'Failed to unlink that account' }) + } +} + +module.exports = { listLinks, removeLink } diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js new file mode 100644 index 0000000..feed707 --- /dev/null +++ b/server/router/admin/usersRust.router.js @@ -0,0 +1,73 @@ +// ── The `admin.users.detail` extension slot ─────────────────────────────── +// +// R13's first slot, and the phase criterion in one file: *an operator sees the +// Steam id inside core's own user page*. +// +// MODULE_API.md §2.4's fourth mount shape — module routes hanging off a CORE +// resource. `/admin/users/:id` is a URL core owns and this module has something +// to say about it, so the routes cannot move behind a `/rust` prefix and cannot +// be registered anywhere else either. Core declares the slot; a module fills it, +// and only one module may. +// +// Three things about this router that are not true of the other three: +// +// • **`mergeParams: true`**, because the user id belongs to the parent. Without +// it `req.params.id` is undefined and every statement here silently scopes to +// nothing. +// • **The paths keep the module's own segment** (`/rust/links`, not `/links`). +// Core owns the resource and other modules may fill their own slots on other +// resources; a bare `/links` would be this module claiming a word on a URL it +// does not own. +// • **The gate is stricter than the admin tier's.** Core's users router is +// `requireRole('admin')` and the slot is mounted inside it, so editors and +// moderators never reach here — which is right for a surface that can sever +// what phases 7 and 13 grant against. +// +// The client half is registered under the SAME name (`registry.registerExtension` +// in `entry.jsx`) and builds its own client for these two routes; a slot passes a +// component `userId` and nothing else. + +const core = require('../../core') + +const express = core.express +const { param } = core.validator + +const usersRust = require('./usersRust.controller') +const { validate } = core.middleware + +// Same bound the player tier states, for the same reason: nothing but digits +// reaches a `WHERE steam_id = ?`. +const STEAM_ID_RE = /^[0-9]{5,32}$/ + +const usersRustRouter = express.Router({ mergeParams: true }) + +usersRustRouter.get( + '/rust/links', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s linked Steam accounts and their Rust record (admin only)' + // #swagger.description = 'Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminLinkList" } } } } */ + param('id').isInt(), + validate, + usersRust.listLinks, +) + +usersRustRouter.delete( + '/rust/links/:steamId', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Sever a user’s Steam link (admin only)' + // #swagger.description = 'Staff release a link on this user’s behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */ + /* #swagger.responses[404] = { description: 'Not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + param('steamId').matches(STEAM_ID_RE), + validate, + usersRust.removeLink, +) + +module.exports = usersRustRouter diff --git a/server/router/player/rust.controller.js b/server/router/player/rust.controller.js index 463ae16..7240169 100644 --- a/server/router/player/rust.controller.js +++ b/server/router/player/rust.controller.js @@ -1,11 +1,27 @@ // ── Player · Rust — the handlers ────────────────────────────────────────── // -// See the router for why this tier is thin in phase 1. The one thing it must not -// do is reshape the list itself: it calls the same model the public tier does, so -// the two answers cannot drift while they are meant to be the same. +// Two things live here now: the server list as a signed-in caller sees it (phase +// 1's honest placeholder, which must not reshape the list — it calls the same +// model the public tier does so the two cannot drift), and R1's identity link. +// +// ── Every refusal is a sentence, and they are not interchangeable ───────── +// +// The link handler's whole job is turning a discriminated result into the right +// thing to tell a player, and the four wrong answers are wrong in different ways: +// +// • "that code is unknown or expired" → run `/link` again +// • "another account holds that Steam id" → run `/unlink` in game, or ask staff +// • "we could not reach a server" → try again in a minute; the code is fine +// • "no servers are configured" → nothing the player can do at all +// +// A player told to run `/link` again when the server their code came from was +// merely unreachable will run it again, get another code from the same +// unreachable server, and be told the same thing. That is the failure the +// `unsure` branch exists to prevent. const core = require('../../core') +const links = require('../../model/links/links.model') const servers = require('../../model/servers/servers.model') const log = core.logger('player') @@ -19,4 +35,103 @@ async function listServers(req, res) { } } -module.exports = { listServers } +/** GET /player/rust/links — the Steam accounts the caller holds. */ +async function listLinks(req, res) { + try { + res.json({ links: await links.listForUser(req.user.id) }) + } catch (err) { + log.error('failed to read a player’s links', { error: err.message }) + res.status(500).json({ error: 'Failed to read your linked accounts' }) + } +} + +/** + * POST /player/rust/link — redeem a code from `/link` in game. + * + * The fleet loop is the model's (D24); this maps its answer onto a status and a + * sentence. **A refused code is a 400 and an unreachable server is a 503**, + * because a client that cannot tell them apart cannot tell a player whether to + * try again or to go and get a new code. + */ +async function confirmLink(req, res) { + const code = String(req.body.code || '').trim() + + try { + const result = await links.redeem({ code, userId: req.user.id }) + + if (result.ok) { + // Logged on the player tier too, not only for admin writes: this is the + // moment a website account starts being able to hold permissions and + // entitlements in a game, and "when did this account become that Steam id" + // is a question an operator will eventually need answered. + await core.activity.log({ + req, + action: 'rust.account.link', + detail: { steamId: result.link.steamId, serverId: result.link.serverId }, + }) + + return res.json({ linked: true, link: result.link, already: Boolean(result.already) }) + } + + switch (result.reason) { + case 'taken': + // Naming the holder is deliberate and it is not a leak: the player is + // signed in, the account named is one they may well own, and without the + // name the advice ("sign in as that account, or ask staff") is unusable. + return res.status(409).json({ + error: result.username + ? `That Steam account is already linked to ${result.username}. Run /unlink in game to release it.` + : 'That Steam account is already linked to another website account. Run /unlink in game to release it.', + }) + + case 'unsure': + return res.status(503).json({ + error: + 'One of the servers could not be reached, so that code could not be checked. ' + + 'Your code is still good — try again in a minute.', + }) + + case 'offline': + return res.status(503).json({ + error: 'The game servers are unreachable right now — try again in a minute.', + }) + + case 'no-servers': + return res.status(503).json({ error: 'No Rust servers are configured on this site yet.' }) + + default: + return res.status(400).json({ + error: 'That code is unknown or has expired. Type /link in game for a new one.', + }) + } + } catch (err) { + log.error('failed to confirm a link code', { error: err.message }) + return res.status(500).json({ error: 'Failed to confirm that code' }) + } +} + +/** + * DELETE /player/rust/links/:steamId — release a link the caller holds. + * + * Scoped to the caller inside the statement, so "not linked" and "not yours" + * answer the same 404 — a signed-in stranger must not be able to discover which + * Steam ids are linked by deleting them one at a time. + */ +async function removeLink(req, res) { + const { steamId } = req.params + + try { + const removed = await links.unlinkOwned(steamId, req.user.id) + + if (!removed) return res.status(404).json({ error: 'That account is not linked to you' }) + + await core.activity.log({ req, action: 'rust.account.unlink', detail: { steamId } }) + + return res.json({ unlinked: true }) + } catch (err) { + log.error('failed to unlink', { error: err.message }) + return res.status(500).json({ error: 'Failed to unlink that account' }) + } +} + +module.exports = { listServers, listLinks, confirmLink, removeLink } diff --git a/server/router/player/rust.router.js b/server/router/player/rust.router.js index 2119049..ad5195c 100644 --- a/server/router/player/rust.router.js +++ b/server/router/player/rust.router.js @@ -4,38 +4,109 @@ // sits behind `noindex, requireAuth`, so every handler here has a signed-in user // and none of them re-implements that check. // -// ── Why this tier exists in phase 1, and what it honestly holds ─────────── +// ── Why this tier exists in phase 1, and what it holds now ──────────────── // // R14 puts this module on all three tiers from the start, and the loader holds // `module.json`'s `mounts` against what is actually registered in **both** // directions — a declared prefix that never gets a router fails the load. So the // declaration and the registration land together or not at all. // -// What this tier will carry is the signed-in view of a server: the viewer's own -// linked Steam identity, their own presence, their own entitlements. None of that -// exists yet — identity is a later phase — so the one route here answers the -// server list as the signed-in caller sees it, which is currently the same list -// the public tier serves. +// Phase 1 said this tier would carry the signed-in view of a server — the +// viewer's own linked Steam identity, their own presence, their own entitlements +// — and that identity was a later phase. This is that phase: `/links`, `/link` +// and `DELETE /links/:steamId` are R1, and everything phases 7 and 13 hand out is +// hung off the row they write. // -// That is deliberately a real route and not a placeholder: it is the URL the app -// and the SPA will call, and it starts answering correctly now rather than -// changing address later. What it must not become is a second copy of the public -// shape — it delegates to the same model, so the two cannot drift. +// `/servers` stays what it was: the same list the public tier serves, answered on +// the authenticated tier so per-player detail can be added without moving the +// address. It delegates to the same model, so the two cannot drift. const core = require('../../core') const express = core.express -const servers = require('./rust.controller') +const { body, param } = core.validator + +const rust = require('./rust.controller') +const { validate, rateLimit } = core.middleware const playerRustRouter = express.Router() +// A Steam id as the game states it — `BasePlayer.UserIDString`, a 17-digit +// SteamID64. Bounded rather than pinned at 17 because the column is a string and +// a test rig's ids are shorter; what matters is that nothing but digits reaches a +// `WHERE steam_id = ?`. +const STEAM_ID_RE = /^[0-9]{5,32}$/ + +/** + * R1 requires the link code be rate-limited, and this is where that lands. + * + * The code is six characters from a 32-glyph alphabet, so guessing one is a + * 1-in-10⁹ shot — but only while the guesser is made to pay for each attempt. + * Ten per quarter-hour per IP turns that into centuries; without it a script + * could work through the space in an afternoon, and phases 7 and 13 make the + * prize a set of in-game permissions and entitlements rather than a cosmetic + * badge. + * + * Its own limiter rather than core's `accountChangeLimiter`: this is guessing + * somebody else's secret, not changing your own password, and sharing a counter + * would mean one of the two silently sets the policy for the other. + */ +const linkLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + label: 'rust-link-code', + message: 'Too many link attempts. Please try again later.', +}) + playerRustRouter.get( '/servers', // #swagger.tags = ['Player · Rust'] // #swagger.summary = 'The Rust servers, for a signed-in player' // #swagger.description = 'The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.' /* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */ - servers.listServers, + rust.listServers, +) + +playerRustRouter.get( + '/links', + // #swagger.tags = ['Player · Rust'] + // #swagger.summary = 'The Steam accounts the caller has linked' + // #swagger.description = 'Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkList" } } } } */ + rust.listLinks, +) + +playerRustRouter.post( + '/link', + // #swagger.tags = ['Player · Rust'] + // #swagger.summary = 'Link a Steam account with a one-time code from /link in game' + // #swagger.description = 'The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkResult" } } } } */ + /* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'That Steam account is linked to another website account', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Too many link attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'A server could not be reached — the code is still good', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + linkLimiter, + body('code').isString().trim().isLength({ min: 4, max: 32 }), + validate, + rust.confirmLink, +) + +playerRustRouter.delete( + '/links/:steamId', + // #swagger.tags = ['Player · Rust'] + // #swagger.summary = 'Release a Steam account the caller has linked' + // #swagger.description = 'Removes the caller’s own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */ + /* #swagger.responses[404] = { description: 'Not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('steamId').matches(STEAM_ID_RE), + validate, + rust.removeLink, ) module.exports = playerRustRouter diff --git a/server/scripts/swaggerFragment.js b/server/scripts/swaggerFragment.js index 94a9497..09a6c97 100644 --- a/server/scripts/swaggerFragment.js +++ b/server/scripts/swaggerFragment.js @@ -60,6 +60,22 @@ const TIER_BASE = { player: '/api/v1/player', } +// MODULE_API.md §2.4's slot table, and the FOURTH base this generator needs. +// +// Phase 6 found the hole: a slot router is not registered under a tier, so the +// loop below could not see it and the two routes it serves were generated by +// nothing — a fragment that was internally consistent and silently described two +// routes fewer than the module serves. The frozen-manifest check would have +// caught it (every route must have an operation), which is precisely why that +// check exists; this is the fix it points at. +// +// A slot's mount is CORE's, not ours, so it cannot be derived from anything in +// this repo. That makes it the same kind of constant as `TIER_BASE` above, and it +// is held to account the same way: by a real core in the frozen-manifest job. +const SLOT_MOUNT = { + 'admin.users.detail': '/api/v1/admin/users/:id', +} + /** * Run `register()` with a recording api and return `[{ file, prefix, what }]`. * @@ -88,6 +104,15 @@ function mountedRouters() { } } + // A filled slot is a mount too. Registered through a different call, mounted + // on a resource core owns, and — unlike a tier router — carrying the parent's + // `:id` in its own base path. + for (const { slot, router } of api.record.extensions || []) { + const mount = SLOT_MOUNT[slot] + if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`) + mounts.push({ router, prefix: mount, what: `slot ${slot}` }) + } + return mounts.map(({ router, prefix, what }) => { const file = fileOf(router) if (!file) { diff --git a/server/sidecarClient.js b/server/sidecarClient.js index ab52faa..8683704 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -52,18 +52,19 @@ const TIMEOUT_MS = 12000 * here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge * plugin, and `protocol` in its `overlay.toml`. * - * **2 — the read path.** The bump lands here in the same change as the emitters, - * even though this module does not yet consume any of the new frames: the - * sidecar refuses a client declaring a different version with a `409`, so a - * module left on 1 would stop being able to read the server board it has been - * reading all along. A constant that lags the deployment is not a safe default; - * it is an outage with a version number on it. + * **3 — identity.** Protocol 2 was the read path; 3 adds the first message the + * WEBSITE originates (`link.confirm`) and the two account frames the plugin + * emits beside it. The bump lands here in the same change as the emitters, + * because the sidecar refuses a client declaring a different version with a + * `409`: a module left on 2 would stop being able to read the server board it + * has been reading all along. A constant that lags the deployment is not a safe + * default; it is an outage with a version number on it. * * It is sent on every request as `X-RustLink-Version`, which turns a mismatched * deployment into a `409` naming both numbers instead of a parse failure three * layers further in. */ -const PROTOCOL_VERSION = 2 +const PROTOCOL_VERSION = 3 /** What a caller gets back. Shaped once so every call site reads the same. */ function reply(ok, status, data = null) { @@ -189,6 +190,26 @@ const feed = (server, since, limit = 200) => /** Where the sidecar's history currently ends. What a new server's cursor starts at. */ const feedTail = (server) => request(server, '/feed') +/** + * Redeem a one-time link code against one server (protocol 3). + * + * **The only call in this file that is not a GET**, and the only one that asks + * the game a question rather than reading what it already said. The sidecar + * forwards the code to the plugin, which holds the pending codes in memory, and + * hands back what it answers. + * + * **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are + * both answers — the sidecar reserves its own failures for the transport (503 + * when the game is down, 504 when it is up and silent) — and the caller has to + * tell "that code is wrong" from "the game never replied" to say the right thing + * to a player. So the discrimination happens on `data.kind`, not on `ok`. + * + * A code is spent on the plugin's FIRST lookup whether or not it turns out to be + * expired, so this must never be called speculatively for its answer alone. + */ +const confirmLink = (server, code) => + request(server, '/link/confirm', { method: 'POST', body: { code } }) + module.exports = { TIMEOUT_MS, PROTOCOL_VERSION, @@ -199,5 +220,6 @@ module.exports = { boards, feed, feedTail, + confirmLink, joinUrl, } diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 696f8ec..ca16863 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -100,6 +100,101 @@ module.exports = { stale: { type: 'boolean', example: false }, }, }, + RustLink: { + type: 'object', + description: 'One Steam account linked to a website user. Never carries a code.', + properties: { + steamId: { type: 'string', example: '76561198000000000' }, + name: { + type: 'string', + nullable: true, + description: 'What the player was called in game when they linked. A display name only — a Rust name changes on a whim and nothing identifies anybody by it.', + example: 'Wanderer', + }, + serverId: { + type: 'string', + nullable: true, + description: 'Which server minted the code. Not part of the identity — a link is fleet-wide — but it is where a support conversation starts.', + example: 'main', + }, + linkedAt: { type: 'string', format: 'date-time' }, + }, + }, + RustLinkList: { + type: 'object', + description: 'The Steam accounts one website user holds (GET /player/rust/links).', + properties: { + links: { type: 'array', items: { $ref: '#/components/schemas/RustLink' } }, + }, + }, + RustLinkRequest: { + type: 'object', + required: ['code'], + properties: { + code: { + type: 'string', + description: 'The six-character code /link handed the player in game. Good for five minutes, and it works once.', + example: 'K7M2PQ', + }, + }, + }, + RustLinkResult: { + type: 'object', + description: 'The result of redeeming a code.', + properties: { + linked: { type: 'boolean', example: true }, + link: { $ref: '#/components/schemas/RustLink' }, + already: { + type: 'boolean', + description: 'True when this Steam id was already linked to the caller — a second press of the button, not an error.', + example: false, + }, + }, + }, + RustAdminLinkList: { + type: 'object', + description: 'One user’s Rust identity, for the admin.users.detail panel (GET /admin/users/{id}/rust/links).', + properties: { + links: { + type: 'array', + items: { + type: 'object', + properties: { + steamId: { type: 'string', example: '76561198000000000' }, + name: { + type: 'string', + nullable: true, + description: 'What the game last saw this player called, falling back to the name recorded at link time.', + example: 'Wanderer', + }, + linkedName: { type: 'string', nullable: true, example: 'Wanderer' }, + serverId: { type: 'string', nullable: true, example: 'main' }, + linkedAt: { type: 'string', format: 'date-time' }, + firstSeen: { type: 'string', format: 'date-time', nullable: true }, + lastSeen: { type: 'string', format: 'date-time', nullable: true }, + servers: { + type: 'array', + description: 'All-time totals per server, summed across every wipe.', + items: { + type: 'object', + properties: { + serverId: { type: 'string', example: 'main' }, + serverName: { type: 'string', example: 'Main · Vanilla' }, + kills: { type: 'integer', example: 41 }, + deaths: { type: 'integer', example: 37 }, + npcKills: { type: 'integer', example: 120 }, + structures: { type: 'integer', example: 64 }, + playtimeSec: { type: 'integer', example: 43200 }, + wipes: { type: 'integer', example: 2 }, + lastSeen: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, RustSidecarProbe: { type: 'object', description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).', diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index c944ba8..b718ec7 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -28,7 +28,7 @@ test('an unknown kind is not public — the default is deny', () => { assert.equal(catalogue.isPublic('player.location'), false) }) -test('nothing carrying an IP address or a report is public', () => { +test('nothing carrying an IP address, a report or an identity is public', () => { for (const kind of [ 'player.login.attempt', 'player.approved', @@ -36,6 +36,11 @@ test('nothing carrying an IP address or a report is public', () => { 'player.unbanned', 'player.reported', 'entity.destroyed', + // Protocol 3. A link request on a public killfeed would tell everyone which + // Steam id is about to become a named website account, and an unlink would + // say when somebody stopped being one. + 'account.link.requested', + 'account.unlinked', ]) { assert.equal(catalogue.isPublic(kind), false, `${kind} must not be public`) assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`) @@ -82,13 +87,13 @@ test('every kind is classified exactly once', () => { assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length) }) -test('the classification covers exactly the kinds protocol 2 defines', () => { +test('the classification covers exactly the kinds protocol 3 defines', () => { // The spec lives in another repository, so the list is restated here rather // than parsed — and restating it is the point: adding a kind to the protocol // without deciding who may see it has to fail somewhere, and this is where. // // Sourced from docs/rust-link/PROTOCOL.md §8.4. - const PROTOCOL_2 = [ + const PROTOCOL_3 = [ 'player.connected', 'player.disconnected', 'player.respawned', @@ -104,7 +109,9 @@ test('the classification covers exactly the kinds protocol 2 defines', () => { 'server.wipe', 'server.initialized', 'server.shutdown', + 'account.link.requested', + 'account.unlinked', ] - assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_2].sort()) + assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort()) }) diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js new file mode 100644 index 0000000..ee0a18b --- /dev/null +++ b/server/test/identityRoutes.test.js @@ -0,0 +1,82 @@ +// ── The shape of the identity surface ───────────────────────────────────── +// +// Three properties that are invisible in review and expensive in production: +// +// • **the link route is rate-limited** (R1). Six characters from a 32-glyph +// alphabet is a good code only while a guesser is made to pay per attempt, +// and once phase 7 grants permissions against a link, guessing one is a +// privilege-escalation path rather than a nuisance. +// • **the extension router merges its parent's params**. Without +// `mergeParams`, `req.params.id` is `undefined` and every statement in that +// panel silently scopes to no user — a panel that reads as "this user has no +// Rust account" for everybody. +// • **the extension's paths keep the module's own segment.** Core owns +// `/admin/users/:id`; a bare `/links` would be this module claiming a word on +// a URL it does not own, and the next module to fill a slot would collide. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx, fakeApi } = require('./_fakes') + +function register(ctx = fakeCtx()) { + require('../core')._reset() + const api = fakeApi() + require('../index')(ctx, api) + return api +} + +/** `[{ method, path, handlers }]` for one express router. */ +function routesOf(router) { + return router.stack + .filter((layer) => layer.route) + .map((layer) => ({ + path: layer.route.path, + method: Object.keys(layer.route.methods)[0].toUpperCase(), + handlers: layer.route.stack.map((s) => s.handle), + })) +} + +test('the player tier serves the three identity routes, and nothing else new', () => { + const api = register() + const routes = routesOf(api.record.routes.player['/rust']) + + assert.deepEqual( + routes.map((r) => `${r.method} ${r.path}`).sort(), + ['DELETE /links/:steamId', 'GET /links', 'GET /servers', 'POST /link'], + ) +}) + +test('redeeming a code is rate-limited, and by a limiter of its own', () => { + const api = register() + const post = routesOf(api.record.routes.player['/rust']).find((r) => r.method === 'POST') + + // The fake's `rateLimit` hands back a pass-through carrying the options it was + // given, so the policy itself is assertable — a limiter that was quietly + // removed, or one built with core's `accountChangeLimiter` shared counter, + // both fail here. + const limiter = post.handlers.find((h) => h.options && h.options.label === 'rust-link-code') + + assert.ok(limiter, 'POST /link must carry its own rate limiter (R1)') + assert.equal(limiter.options.max, 10) + assert.equal(limiter.options.windowMs, 15 * 60 * 1000) + + // First in the chain: a limiter behind the validator would let an attacker + // spend the cheap half of the request unbounded. + assert.equal(post.handlers[0], limiter) +}) + +test('the admin.users.detail router merges the parent’s params and keeps its own segment', () => { + const api = register() + const slot = api.record.extensions.find((e) => e.slot === 'admin.users.detail') + + assert.ok(slot, 'the server half of admin.users.detail must be registered') + assert.equal(slot.router.mergeParams, true) + + const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort() + assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links']) + + for (const route of routesOf(slot.router)) { + assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`) + } +}) diff --git a/server/test/ingest.test.js b/server/test/ingest.test.js index 2fc4fc7..7fbcdb0 100644 --- a/server/test/ingest.test.js +++ b/server/test/ingest.test.js @@ -327,3 +327,35 @@ test('a board replaces presence rather than appending to it', async () => { assert.match(presence[0].sql, /^DELETE FROM rust_presence/) assert.match(presence[1].sql, /INSERT INTO rust_presence/) }) + +// ── Protocol 3: the frame that changes something other than a counter ───── + +test('an in-game /unlink severs the site link, scoped by Steam id alone', async () => { + const rec = withRecorder() + const ingest = require('../ingest') + + await ingest.apply('main', item('account.unlinked', { steamId: '7656', name: 'Wanderer', origin: 'in-game' })) + + const del = rec.statements.find((st) => st.sql.trim().toUpperCase().startsWith('DELETE')) + + // It arrives on the FEED rather than through a route because the plugin has no + // link to delete — the site is the author of record. And it is the only way out + // of a link on the wrong account, because the site refuses to move a Steam id + // another account already holds (D23). + assert.ok(del, 'an unlink frame must delete the link') + assert.ok(del.sql.includes('rust_account_links')) + assert.deepEqual(del.params, ['7656']) +}) + +test('asking for a code links nothing — the code does not travel on the wire', async () => { + const rec = withRecorder() + const ingest = require('../ingest') + + await ingest.apply('main', item('account.link.requested', { steamId: '7656', name: 'Wanderer', ttlSec: 300 })) + + // The frame exists so an operator can see linking being used. Nothing about it + // is redeemable: the code travels through the player, which is what makes + // typing it proof that they are the one who asked. + assert.equal(rec.touching('rust_account_links').length, 0) + assert.equal(rec.touching('rust_players').length, 1) +}) diff --git a/server/test/links.test.js b/server/test/links.test.js new file mode 100644 index 0000000..a6a5731 --- /dev/null +++ b/server/test/links.test.js @@ -0,0 +1,276 @@ +// ── Identity: the fleet loop and the refusal ────────────────────────────── +// +// Two things in this file are worth more than the rest, and both are about +// telling answers apart that a naive implementation collapses: +// +// • **A code is minted by ONE server** and the player types six characters into +// a browser. Every server is asked in turn (D24), and "every reachable server +// said no" is NOT the same answer as "a server could not be reached" — the +// second is the case where the player's code is perfectly good and the advice +// "run /link again" is useless, because it sends them back to the server that +// is down. +// +// • **A Steam id another account holds is refused, never moved** (D23). Once +// phase 7 grants permissions against a link and phase 13 hangs entitlements +// off it, a silent move is an account takeover performed by typing six +// characters. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +/** + * Installs a ctx whose `db.query` answers from a small script. + * + * `rows` is consulted by the first word of the statement, which is as much SQL as + * these tests should know: the point of each one is the decision the model makes, + * not the shape of a SELECT it delegates. + */ +function withCore({ select = [], onInsert = null } = {}) { + const queries = [] + + const ctx = fakeCtx({ + db: { + query: (sql, params) => { + queries.push({ sql, params }) + + const verb = sql.trim().split(/\s+/)[0].toUpperCase() + + if (verb === 'SELECT') { + const next = Array.isArray(select) ? select.shift() : select + return Promise.resolve(next || []) + } + + if (verb === 'INSERT' && onInsert) return onInsert(params) + + return Promise.resolve({ affectedRows: 1 }) + }, + pool: {}, + }, + }) + + require('../core')._reset() + require('../core').init(ctx) + + return { ctx, queries } +} + +/** A fleet of `n` servers, and a sidecar that answers from a script. */ +function fleetOf(replies) { + const servers = require('../model/servers/servers.model') + const sidecar = require('../sidecarClient') + + const asked = [] + const ids = Object.keys(replies) + + servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' })) + + sidecar.confirmLink = async (server, code) => { + asked.push({ server: server.id, code }) + return replies[server.id] + } + + return asked +} + +/** The two replies a reachable sidecar can carry, and the one it cannot. */ +const linkOk = (steamId, name) => ({ ok: true, status: 'ok', data: { kind: 'link.ok', steamId, name } }) +const linkRefused = { ok: true, status: 'ok', data: { kind: 'link.error', reason: 'unknown' } } +const unreachable = { ok: false, status: 'transport-error', data: null } + +test('every server is asked until one recognises the code, and the one that answered is recorded', async () => { + const { queries } = withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'b' }]] }) + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: linkRefused, b: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, true) + assert.equal(result.link.steamId, '7656') + + // Both servers were asked, in order, with the same code — and the loop stopped + // at the one that said yes. + assert.deepEqual(asked, [{ server: 'a', code: 'K7M2PQ' }, { server: 'b', code: 'K7M2PQ' }]) + + // The server that minted it is stored. It is not part of the identity — a link + // is fleet-wide — but it is where a support conversation starts. + const insert = queries.find((q) => q.sql.trim().toUpperCase().startsWith('INSERT')) + assert.deepEqual(insert.params, ['7656', 4, 'Wanderer', 'b']) +}) + +test('a server after the one that answered is never asked', async () => { + withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] }) + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused, c: linkRefused }) + + await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + // A code is spent on the plugin's FIRST lookup, so carrying on after a yes + // would be asking four other game hosts to look up a secret that has already + // been redeemed. + assert.deepEqual(asked.map((a) => a.server), ['a']) +}) + +test('a Steam id another account holds is refused, not moved — and the loop stops', async () => { + // The whole of D23 in one assertion. The holder is named because the player is + // signed in and the advice ("sign in as that account, or run /unlink") is + // unusable without it. + withCore({ select: [[{ steamId: '7656', userId: 9, username: 'someone-else' }]] }) + const links = require('../model/links/links.model') + + const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, false) + assert.equal(result.reason, 'taken') + assert.equal(result.username, 'someone-else') + + // Asking the rest of the fleet would answer the same question more slowly: the + // verdict is about the Steam id, not about this server. + assert.deepEqual(asked.map((a) => a.server), ['a']) +}) + +test('a code already redeemed by the SAME user is a success, not an error', async () => { + withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]] }) + const links = require('../model/links/links.model') + + fleetOf({ a: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + // A player who pressed the button twice, or whose confirmation was applied on a + // request that then timed out. Reporting that as a failure would send them to + // run `/link` again for a link they already have. + assert.equal(result.ok, true) + assert.equal(result.already, true) +}) + +test('"every reachable server refused" is not the same answer as "a server was unreachable"', async () => { + withCore() + const links = require('../model/links/links.model') + + fleetOf({ a: linkRefused, b: unreachable }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + // The failure this prevents: a player linked on the server that is down, is + // told their code is wrong, runs `/link` again on that same server, and is told + // the same thing for as long as it stays down. + assert.equal(result.reason, 'unsure') +}) + +test('a fleet nobody can reach is offline, and a fleet that all refused is a bad code', async () => { + withCore() + let links = require('../model/links/links.model') + + fleetOf({ a: unreachable, b: unreachable }) + assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'offline') + + withCore() + links = require('../model/links/links.model') + + fleetOf({ a: linkRefused, b: linkRefused }) + assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'rejected') +}) + +test('a site with no servers configured says so rather than that the code is wrong', async () => { + withCore() + const links = require('../model/links/links.model') + + fleetOf({}) + + assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'no-servers') +}) + +test('two confirmations of one Steam id race into the primary key, not into a 500', async () => { + // The window the PRIMARY KEY exists for: both requests read "not linked", both + // write. The second insert is refused by the key, and the refusal has to become + // the same sentence the check above produces — otherwise one of two players + // pressing a button at the same moment gets an internal error. + const dup = Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' }) + + withCore({ + select: [[], [{ steamId: '7656', userId: 9, username: 'someone-else' }]], + onInsert: () => Promise.reject(dup), + }) + const links = require('../model/links/links.model') + + fleetOf({ a: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, false) + assert.equal(result.reason, 'taken') + assert.equal(result.username, 'someone-else') +}) + +test('the same race, won by the caller, is a success', async () => { + const dup = Object.assign(new Error('duplicate'), { errno: 1062 }) + + withCore({ + select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]], + onInsert: () => Promise.reject(dup), + }) + const links = require('../model/links/links.model') + + fleetOf({ a: linkOk('7656', 'Wanderer') }) + + const result = await links.redeem({ code: 'K7M2PQ', userId: 4 }) + + assert.equal(result.ok, true) + assert.equal(result.already, true) +}) + +test('a link is never shaped with anything a code could be recovered from', async () => { + withCore() + const links = require('../model/links/links.model') + + const shaped = links.shape({ + steamId: '7656', + userId: 4, + username: 'someone', + name: 'Wanderer', + serverId: 'a', + linkedAt: '2026-09-21T00:00:00Z', + }) + + // `userId` and `username` are deliberately absent: the caller is the user, and + // a list that carried somebody's website username would be a different fact + // from "you hold this Steam id". + assert.deepEqual(Object.keys(shaped).sort(), ['linkedAt', 'name', 'serverId', 'steamId']) +}) + +test('an unlink is scoped by user in the statement, not checked before it', async () => { + const { queries } = withCore() + const links = require('../model/links/links.model') + + await links.unlinkOwned('7656', 4) + + const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE')) + + // Read-then-write would leave a gap between the ownership test and the + // deletion; one statement closes it, and the row count is what tells "removed" + // from "was not yours". + assert.ok(del.sql.includes('user_id = ?')) + assert.deepEqual(del.params, ['7656', 4]) +}) + +test('the in-game unlink is scoped by Steam id alone, because that is the authority', async () => { + const { queries } = withCore() + const links = require('../model/links/links.model') + + await links.unlinkFromGame('7656') + + const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE')) + + // Whoever is connected to the game as that Steam account is who it is — a + // stronger proof of ownership than the site can obtain any other way. Scoping + // this by website user would make `/unlink` fail for the one player who needs + // it: the one who linked the wrong account. + assert.ok(!del.sql.includes('user_id')) + assert.deepEqual(del.params, ['7656']) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index 096ae61..8b797bb 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -148,6 +148,290 @@ } } }, + "/api/v1/admin/users/{id}/rust/links": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s linked Steam accounts and their Rust record (admin only)", + "description": "Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustAdminLinkList" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/rust/links/{steamId}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Sever a user’s Steam link (admin only)", + "description": "Staff release a link on this user’s behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "steamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Steam id to release." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "unlinked": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "404": { + "description": "Not linked to this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/rust/link": { + "post": { + "tags": [ + "Player · Rust" + ], + "summary": "Link a Steam account with a one-time code from /link in game", + "description": "The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "That Steam account is linked to another website account", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many link attempts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "A server could not be reached — the code is still good", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustLinkRequest" + } + } + } + } + } + }, + "/api/v1/player/rust/links": { + "get": { + "tags": [ + "Player · Rust" + ], + "summary": "The Steam accounts the caller has linked", + "description": "Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustLinkList" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/rust/links/{steamId}": { + "delete": { + "tags": [ + "Player · Rust" + ], + "summary": "Release a Steam account the caller has linked", + "description": "Removes the caller’s own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.", + "parameters": [ + { + "name": "steamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Steam id to release." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "unlinked": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "404": { + "description": "Not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/player/rust/servers": { "get": { "tags": [ @@ -854,6 +1138,517 @@ } } }, + "RustLink": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One Steam account linked to a website user. Never carries a code." + }, + "properties": { + "type": "object", + "properties": { + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "What the player was called in game when they linked. A display name only — a Rust name changes on a whim and nothing identifies anybody by it." + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Which server minted the code. Not part of the identity — a link is fleet-wide — but it is where a support conversation starts." + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "linkedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "RustLinkList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The Steam accounts one website user holds (GET /player/rust/links)." + }, + "properties": { + "type": "object", + "properties": { + "links": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustLink" + } + } + } + } + } + } + }, + "RustLinkRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "code" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The six-character code /link handed the player in game. Good for five minutes, and it works once." + }, + "example": { + "type": "string", + "example": "K7M2PQ" + } + } + } + } + } + } + }, + "RustLinkResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The result of redeeming a code." + }, + "properties": { + "type": "object", + "properties": { + "linked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "link": { + "$ref": "#/components/schemas/RustLink" + }, + "already": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True when this Steam id was already linked to the caller — a second press of the button, not an error." + }, + "example": { + "type": "boolean", + "example": false + } + } + } + } + } + } + }, + "RustAdminLinkList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One user’s Rust identity, for the admin.users.detail panel (GET /admin/users/{id}/rust/links)." + }, + "properties": { + "type": "object", + "properties": { + "links": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "What the game last saw this player called, falling back to the name recorded at link time." + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "linkedName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "linkedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "firstSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "All-time totals per server, summed across every wipe." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "serverName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Main · Vanilla" + } + } + }, + "kills": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 41 + } + } + }, + "deaths": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 37 + } + } + }, + "npcKills": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 120 + } + } + }, + "structures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 64 + } + } + }, + "playtimeSec": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 43200 + } + } + }, + "wipes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "lastSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, "RustSidecarProbe": { "type": "object", "properties": { -- 2.49.1 From 0a1e558942dd2e1eee56131bf1ef20de3156c821 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 08:19:41 -0500 Subject: [PATCH 03/51] test(rust): grow the mount check for the slot it predicted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `the manifest and the module's declared mounts agree` was written in phase 1 with its own exception named in a comment: when `admin.users.detail` arrives, its routes live on a resource core owns and the test must grow the exception deliberately rather than let a route outside every declared mount arrive unnoticed. This is that growth, and the test did its job — it failed on the first run after the slot was filled. A route is now legitimate if it is under a declared prefix OR under the mount of a slot `module.json` declares, and a declared slot that contributes no route fails too: core never checks that a declared slot was filled (`checkDeclared` covers `mounts` alone), so this is the only place an exception widening the check for nothing is noticed. Verified by pointing the slot mount at a path nothing serves and watching it fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- server/test/frozenManifest.test.js | 49 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/server/test/frozenManifest.test.js b/server/test/frozenManifest.test.js index 39da6e7..bb85514 100644 --- a/server/test/frozenManifest.test.js +++ b/server/test/frozenManifest.test.js @@ -157,21 +157,50 @@ test('every path in the fragment is fully qualified', () => { test("the manifest and the module's declared mounts agree", () => { const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')) - const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8')) + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8')) const declared = [] - for (const [tier, prefixes] of Object.entries(mounts)) { + for (const [tier, prefixes] of Object.entries(manifest.mounts)) { for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`) } - // Every route this module serves is under a prefix it declared. There is no - // exception here yet, and that is the point of asserting it now: phase 6 adds - // the `admin.users.detail` extension slot, whose routes live under core's - // `/api/v1/admin/users/` rather than under any mount of ours (§2.4). When that - // arrives this test must grow the exception deliberately, rather than a route - // outside every declared mount arriving unnoticed. + // **The exception this test predicted, now grown deliberately.** Phase 6 fills + // `admin.users.detail`, whose routes live on a resource CORE owns + // (`/api/v1/admin/users/:id`) rather than under any mount of ours — §2.4's + // fourth mount shape. So a route is legitimate if it is under a declared + // prefix, or under the mount of a slot this module declares. + // + // The slot's mount is restated here rather than imported, for the same reason + // the protocol catalogue is restated in `catalogue.test.js`: it is CORE's + // constant, and a module that derived it from its own generator would be + // checking that file against itself. + const SLOT_MOUNT = { 'admin.users.detail': '/api/v1/admin/users/' } + + const slots = (manifest.extensions || []).map((slot) => { + const mount = SLOT_MOUNT[slot] + assert.ok(mount, `module.json declares slot "${slot}", which §2.4's table does not list`) + return { slot, mount } + }) + + const used = new Set() + for (const route of routes) { - const under = declared.some((d) => route.path.startsWith(d)) - assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`) + if (declared.some((d) => route.path.startsWith(d))) continue + + const slot = slots.find((s) => route.path.startsWith(s.mount)) + assert.ok( + slot, + `${route.method} ${route.path} is served from outside every mount module.json declares, ` + + 'and outside every slot it fills', + ) + used.add(slot.slot) + } + + // The other half, and the reason the exception is narrow: a declared slot that + // contributes no route is an exception widening this check for nothing. Core + // never checks that a declared slot was filled (`checkDeclared` covers `mounts` + // alone), so this is the only place it is noticed. + for (const { slot } of slots) { + assert.ok(used.has(slot), `module.json declares "${slot}" but no route in the manifest comes from it`) } }) -- 2.49.1 From 0876a1d5683d243844b434bd3e47827ab4e03475 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 17:34:21 -0500 Subject: [PATCH 04/51] fix(rust): answer refusals in the field core reads, and show the name the game last saw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two defects the phase 6 browser walk found and #6 described but did not carry. They were written, walked and left uncommitted; `edge` still has the shapes the walk condemned. **Every refusal sentence was invisible.** Core's request primitive reads one field — `(data && data.message) || res.statusText` — and this module has answered `{ error: … }` since phase 1. It got away with it because every failure until phase 6 landed in `ErrorState` on a page whose whole content was missing, where a generic sentence is honest. A form is different: the sentence IS the outcome, and the link page showed *Service Unavailable* for all four of the refusals phase 6 exists to write. All 23 bodies now answer in `message` — core's `Error` schema, which these routes' own `#swagger.responses` already referenced, so the annotations stop being a claim the handlers contradict. `test/errorShape.test.js` drives each outcome rather than grepping for the field, and asserts the half that is easy to leave behind: a body carrying BOTH fields renders correctly in a browser and keeps the wrong shape alive for the next route that copies it. **The player saw a stale name.** `/player/rust` showed the name recorded at link time while the admin panel showed the one the game last saw — the same person labelled two ways on one site, because a Rust name changes on a whim and only the admin read joined `rust_players`. A LEFT JOIN, because an account can be linked and never played on. 123 server tests, 39 client tests, `check:imports`, `check:bundle`, `check:swagger`, `check:externals` — all green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- server/model/links/links.db.js | 24 +++- server/model/links/links.model.js | 15 ++- server/router/admin/rust.controller.js | 14 +- server/router/admin/usersRust.controller.js | 6 +- server/router/player/rust.controller.js | 20 +-- server/router/public/rust.controller.js | 14 +- server/test/errorShape.test.js | 134 ++++++++++++++++++++ server/test/links.test.js | 18 +++ 8 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 server/test/errorShape.test.js diff --git a/server/model/links/links.db.js b/server/model/links/links.db.js index 295bb36..b25d33f 100644 --- a/server/model/links/links.db.js +++ b/server/model/links/links.db.js @@ -28,14 +28,26 @@ async function getBySteamId(steamId) { return rows[0] } -/** Every Steam account one website user holds, newest first. */ +/** + * Every Steam account one website user holds, newest first. + * + * **It joins `rust_players` for the name the game last saw**, and that is not a + * convenience. The name on the LINK is what the player was called at the moment + * they linked, which is a Rust name and changes on a whim — so a player who has + * renamed since sees a name they no longer use, on the one page of the site that + * is about who they are. The admin panel already preferred the newer one; this + * is the same rule applied where the person themselves is reading. + * + * A LEFT JOIN, because a player can link an account and never play on it. + */ async function listForUser(userId) { return core.query( - `SELECT steam_id AS steamId, user_id AS userId, name, server_id AS serverId, - linked_at AS linkedAt - FROM ${LINKS} - WHERE user_id = ? - ORDER BY linked_at DESC`, + `SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId, + l.linked_at AS linkedAt, p.name AS playerName + FROM ${LINKS} l + LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id + WHERE l.user_id = ? + ORDER BY l.linked_at DESC`, [userId], ) } diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js index 9962cab..3d76b8b 100644 --- a/server/model/links/links.model.js +++ b/server/model/links/links.model.js @@ -34,9 +34,20 @@ function shape(row) { } } -/** The Steam accounts one website user holds. */ +/** + * The Steam accounts one website user holds. + * + * The name is the one the GAME last saw, falling back to the one recorded when + * they linked — the rule the admin panel already used, applied on the page the + * player themselves reads. A browser walk found the two disagreeing: staff saw + * `Wanderer` and the player saw `Wanderer-old`, for the same person on the same + * site. + */ async function listForUser(userId) { - return (await db.listForUser(userId)).map(shape) + return (await db.listForUser(userId)).map((row) => ({ + ...shape(row), + name: row.playerName || row.name || null, + })) } /** True when this user holds this Steam id. The ownership gate every player read uses. */ diff --git a/server/router/admin/rust.controller.js b/server/router/admin/rust.controller.js index 6d006dd..34210d1 100644 --- a/server/router/admin/rust.controller.js +++ b/server/router/admin/rust.controller.js @@ -23,7 +23,7 @@ async function listServers(req, res) { res.json({ servers: await servers.listForAdmin() }) } catch (err) { log.error('failed to read the server list', { error: err.message }) - res.status(500).json({ error: 'Failed to read the server list' }) + res.status(500).json({ message: 'Failed to read the server list' }) } } @@ -39,7 +39,7 @@ async function putServer(req, res) { // Refusing it up front costs one round trip and saves that hunt. An EXISTING // row is a different case: omitting the token is how you say "leave it". if (!existing && !sidecarToken) { - return res.status(400).json({ error: 'A new server needs its sidecar token' }) + return res.status(400).json({ message: 'A new server needs its sidecar token' }) } await db.upsertServer({ @@ -71,7 +71,7 @@ async function putServer(req, res) { return res.status(204).end() } catch (err) { log.error('failed to save a server', { server: id, error: err.message }) - return res.status(500).json({ error: 'Failed to save the server' }) + return res.status(500).json({ message: 'Failed to save the server' }) } } @@ -80,7 +80,7 @@ async function deleteServer(req, res) { try { const existing = await db.getServer(id) - if (!existing) return res.status(404).json({ error: 'No such server' }) + if (!existing) return res.status(404).json({ message: 'No such server' }) await db.deleteServer(id) await core.activity.log({ req, action: 'rust.server.delete', detail: { server: id } }) @@ -88,7 +88,7 @@ async function deleteServer(req, res) { return res.status(204).end() } catch (err) { log.error('failed to delete a server', { server: id, error: err.message }) - return res.status(500).json({ error: 'Failed to delete the server' }) + return res.status(500).json({ message: 'Failed to delete the server' }) } } @@ -106,7 +106,7 @@ async function testServer(req, res) { try { const row = await db.getServer(id) - if (!row) return res.status(404).json({ error: 'No such server' }) + if (!row) return res.status(404).json({ message: 'No such server' }) const result = await sidecar.health(servers.withToken(row)) @@ -125,7 +125,7 @@ async function testServer(req, res) { }) } catch (err) { log.error('failed to probe a sidecar', { server: id, error: err.message }) - return res.status(500).json({ error: 'Failed to probe the sidecar' }) + return res.status(500).json({ message: 'Failed to probe the sidecar' }) } } diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js index f26590d..6fb2592 100644 --- a/server/router/admin/usersRust.controller.js +++ b/server/router/admin/usersRust.controller.js @@ -27,7 +27,7 @@ async function listLinks(req, res) { res.json({ links: await links.forAdmin(req.params.id) }) } catch (err) { log.error('failed to read a user’s Rust links', { error: err.message }) - res.status(500).json({ error: 'Failed to read this user’s Rust accounts' }) + res.status(500).json({ message: 'Failed to read this user’s Rust accounts' }) } } @@ -50,7 +50,7 @@ async function removeLink(req, res) { try { const removed = await links.unlinkOwned(steamId, userId) - if (!removed) return res.status(404).json({ error: 'That account is not linked to this user' }) + if (!removed) return res.status(404).json({ message: 'That account is not linked to this user' }) // The one write this panel has, so it is the one thing here worth an audit // row: after phase 7 a link is what permissions are granted against, and @@ -64,7 +64,7 @@ async function removeLink(req, res) { return res.json({ unlinked: true }) } catch (err) { log.error('failed to unlink a Steam account', { error: err.message }) - return res.status(500).json({ error: 'Failed to unlink that account' }) + return res.status(500).json({ message: 'Failed to unlink that account' }) } } diff --git a/server/router/player/rust.controller.js b/server/router/player/rust.controller.js index 7240169..77161d2 100644 --- a/server/router/player/rust.controller.js +++ b/server/router/player/rust.controller.js @@ -31,7 +31,7 @@ async function listServers(req, res) { res.json({ servers: await servers.listPublic() }) } catch (err) { log.error('failed to read the server list', { error: err.message }) - res.status(500).json({ error: 'Failed to read the server list' }) + res.status(500).json({ message: 'Failed to read the server list' }) } } @@ -41,7 +41,7 @@ async function listLinks(req, res) { res.json({ links: await links.listForUser(req.user.id) }) } catch (err) { log.error('failed to read a player’s links', { error: err.message }) - res.status(500).json({ error: 'Failed to read your linked accounts' }) + res.status(500).json({ message: 'Failed to read your linked accounts' }) } } @@ -79,34 +79,34 @@ async function confirmLink(req, res) { // signed in, the account named is one they may well own, and without the // name the advice ("sign in as that account, or ask staff") is unusable. return res.status(409).json({ - error: result.username + message: result.username ? `That Steam account is already linked to ${result.username}. Run /unlink in game to release it.` : 'That Steam account is already linked to another website account. Run /unlink in game to release it.', }) case 'unsure': return res.status(503).json({ - error: + message: 'One of the servers could not be reached, so that code could not be checked. ' + 'Your code is still good — try again in a minute.', }) case 'offline': return res.status(503).json({ - error: 'The game servers are unreachable right now — try again in a minute.', + message: 'The game servers are unreachable right now — try again in a minute.', }) case 'no-servers': - return res.status(503).json({ error: 'No Rust servers are configured on this site yet.' }) + return res.status(503).json({ message: 'No Rust servers are configured on this site yet.' }) default: return res.status(400).json({ - error: 'That code is unknown or has expired. Type /link in game for a new one.', + message: 'That code is unknown or has expired. Type /link in game for a new one.', }) } } catch (err) { log.error('failed to confirm a link code', { error: err.message }) - return res.status(500).json({ error: 'Failed to confirm that code' }) + return res.status(500).json({ message: 'Failed to confirm that code' }) } } @@ -123,14 +123,14 @@ async function removeLink(req, res) { try { const removed = await links.unlinkOwned(steamId, req.user.id) - if (!removed) return res.status(404).json({ error: 'That account is not linked to you' }) + if (!removed) return res.status(404).json({ message: 'That account is not linked to you' }) await core.activity.log({ req, action: 'rust.account.unlink', detail: { steamId } }) return res.json({ unlinked: true }) } catch (err) { log.error('failed to unlink', { error: err.message }) - return res.status(500).json({ error: 'Failed to unlink that account' }) + return res.status(500).json({ message: 'Failed to unlink that account' }) } } diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js index 8b799b6..b208378 100644 --- a/server/router/public/rust.controller.js +++ b/server/router/public/rust.controller.js @@ -21,7 +21,7 @@ async function listServers(req, res) { res.json({ servers: await servers.listPublic() }) } catch (err) { log.error('failed to read the server list', { error: err.message }) - res.status(500).json({ error: 'Failed to read the server list' }) + res.status(500).json({ message: 'Failed to read the server list' }) } } @@ -39,13 +39,13 @@ async function getServer(req, res) { try { const server = await servers.getPublic(req.params.id) if (!server) { - res.status(404).json({ error: 'No such server' }) + res.status(404).json({ message: 'No such server' }) return } res.json({ server }) } catch (err) { log.error('failed to read a server', { server: req.params.id, error: err.message }) - res.status(500).json({ error: 'Failed to read the server' }) + res.status(500).json({ message: 'Failed to read the server' }) } } @@ -69,7 +69,7 @@ async function listEvents(req, res) { }) } catch (err) { log.error('failed to read events', { server: req.params.id, error: err.message }) - res.status(500).json({ error: 'Failed to read events' }) + res.status(500).json({ message: 'Failed to read events' }) } } @@ -85,7 +85,7 @@ async function listLeaderboard(req, res) { }) } catch (err) { log.error('failed to read the leaderboard', { server: req.params.id, error: err.message }) - res.status(500).json({ error: 'Failed to read the leaderboard' }) + res.status(500).json({ message: 'Failed to read the leaderboard' }) } } @@ -94,7 +94,7 @@ async function listWipes(req, res) { res.json({ wipes: await events.wipes(req.params.id) }) } catch (err) { log.error('failed to read wipes', { server: req.params.id, error: err.message }) - res.status(500).json({ error: 'Failed to read wipes' }) + res.status(500).json({ message: 'Failed to read wipes' }) } } @@ -103,7 +103,7 @@ async function listOnline(req, res) { res.json({ players: await events.online(req.params.id) }) } catch (err) { log.error('failed to read presence', { server: req.params.id, error: err.message }) - res.status(500).json({ error: 'Failed to read who is online' }) + res.status(500).json({ message: 'Failed to read who is online' }) } } diff --git a/server/test/errorShape.test.js b/server/test/errorShape.test.js new file mode 100644 index 0000000..7b77f74 --- /dev/null +++ b/server/test/errorShape.test.js @@ -0,0 +1,134 @@ +// ── The field an error has to be in ─────────────────────────────────────── +// +// **The walk found this, and no test could have.** Core's request primitive is +// the only thing that reads a module's failures: +// +// const message = (data && data.message) || res.statusText || 'Request failed' +// +// So a body shaped `{ error: '…' }` is not rendered as a worse message — it is +// not rendered at all. The player sees `Service Unavailable`, which is what the +// link page showed for every one of the four sentences this phase exists to +// write, until a browser said so. +// +// This module answered `{ error }` from its first phase and got away with it, +// because until now every failure landed in `ErrorState` on a page whose whole +// content was missing — where a generic sentence is honest. A form is different: +// the sentence IS the outcome, and the four are not interchangeable. +// +// The rule is core's `Error` schema (`{ message }`), which every one of this +// module's `#swagger.responses` already pointed at. So this suite is the schema +// those annotations claim, asserted against what the handlers actually send. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +function withCore() { + require('../core')._reset() + require('../core').init(fakeCtx()) +} + +/** A response double that records the status and the body. */ +function fakeRes() { + const res = { + statusCode: 200, + body: null, + status(code) { + res.statusCode = code + return res + }, + json(body) { + res.body = body + return res + }, + } + return res +} + +/** Every outcome `redeem` can answer, and the status each has to become. */ +const OUTCOMES = [ + [{ ok: false, reason: 'taken', username: 'someone-else' }, 409, /already linked to someone-else/], + [{ ok: false, reason: 'unsure' }, 503, /still good/], + [{ ok: false, reason: 'offline' }, 503, /unreachable/], + [{ ok: false, reason: 'no-servers' }, 503, /No Rust servers/], + [{ ok: false, reason: 'rejected' }, 400, /unknown or has expired/], +] + +test('every refusal reaches the player as a sentence, in the field core reads', async () => { + for (const [outcome, status, matches] of OUTCOMES) { + withCore() + const links = require('../model/links/links.model') + const controller = require('../router/player/rust.controller') + + links.redeem = async () => outcome + + const res = fakeRes() + await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res) + + assert.equal(res.statusCode, status, `${outcome.reason} must be ${status}`) + assert.equal(typeof res.body.message, 'string', `${outcome.reason} sent no \`message\``) + assert.match(res.body.message, matches) + + // The half that is easy to leave behind while fixing this: a body carrying + // BOTH fields reads correctly in a browser and keeps the wrong shape alive + // for the next route that copies it. + assert.equal(res.body.error, undefined, `${outcome.reason} still carries an \`error\` field`) + } +}) + +test('the five outcomes are five different statuses-and-sentences, not one', async () => { + const seen = new Set() + + for (const [outcome] of OUTCOMES) { + withCore() + const links = require('../model/links/links.model') + const controller = require('../router/player/rust.controller') + + links.redeem = async () => outcome + + const res = fakeRes() + await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res) + seen.add(res.body.message) + } + + // "That code is wrong" and "we could not reach the server that has it" send a + // player to do different things, and one of the two is a dead end when it is + // wrong — they run /link again on the server that is down and get the same + // answer for as long as it stays down. + assert.equal(seen.size, OUTCOMES.length, 'two outcomes tell the player the same thing') +}) + +test('no handler in this module answers in a field core cannot read', async () => { + // The other controllers, the same way — driven rather than grepped, because the + // shape that matters is what a handler SENDS. Each is given a model that throws, + // which is every controller's own 500 path and the one branch they all have. + withCore() + + const cases = [ + ['public', '../router/public/rust.controller', 'listServers', { params: {}, query: {} }], + ['player', '../router/player/rust.controller', 'listServers', { params: {}, query: {}, user: { id: 4 } }], + ['player', '../router/player/rust.controller', 'listLinks', { params: {}, user: { id: 4 } }], + ['admin', '../router/admin/rust.controller', 'listServers', { params: {}, query: {} }], + ['slot', '../router/admin/usersRust.controller', 'listLinks', { params: { id: '4' } }], + ] + + for (const [tier, modulePath, handler, req] of cases) { + withCore() + + // Core's `query` is the fake's spy; make it throw so every handler takes its + // failure branch. + require('../core')._reset() + require('../core').init(fakeCtx({ + db: { query: () => Promise.reject(new Error('the database is not there')), pool: {} }, + })) + + const controller = require(modulePath) + const res = fakeRes() + await controller[handler](req, res) + + assert.equal(res.statusCode, 500, `${tier}.${handler} did not fail`) + assert.equal(typeof res.body.message, 'string', `${tier}.${handler} sent no \`message\``) + assert.equal(res.body.error, undefined, `${tier}.${handler} answers in \`error\``) + } +}) diff --git a/server/test/links.test.js b/server/test/links.test.js index a6a5731..3a47cb5 100644 --- a/server/test/links.test.js +++ b/server/test/links.test.js @@ -274,3 +274,21 @@ test('the in-game unlink is scoped by Steam id alone, because that is the author assert.ok(!del.sql.includes('user_id')) assert.deepEqual(del.params, ['7656']) }) + +test('a player sees the name the GAME last saw, not the one they linked under', async () => { + withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: 'Wanderer', serverId: 'a', linkedAt: 'x' }]] }) + const links = require('../model/links/links.model') + + const [link] = await links.listForUser(4) + + // Found in a browser: staff saw `Wanderer` on the admin panel and the player + // saw `Wanderer-old` on their own page — the same person, labelled two ways on + // one site, because a Rust name changes on a whim and only one of the two reads + // was joining `rust_players`. + assert.equal(link.name, 'Wanderer') + + // And the fallback still holds for a link whose account has never played. + withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: null, linkedAt: 'x' }]] }) + const again = require('../model/links/links.model') + assert.equal((await again.listForUser(4))[0].name, 'Wanderer-old') +}) -- 2.49.1 From 43147b796a476ca3e6ab737db9e693421a2afc15 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 18:28:32 -0500 Subject: [PATCH 05/51] =?UTF-8?q?feat(rust):=20site-owned=20permissions=20?= =?UTF-8?q?=E2=80=94=20the=20site=20is=20the=20author,=20the=20game=20is?= =?UTF-8?q?=20the=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- ci/bundle.json | 1 + client/src/api.js | 72 +- client/src/entry.jsx | 27 +- client/src/icons.jsx | 18 +- client/src/routes/admin/Permissions.jsx | 617 ++++++ client/src/routes/admin/UserRustSections.jsx | 151 +- routes.manifest.json | 70 + server/boot.js | 10 +- server/catalogue.js | 4 + server/db/purge.sql | 11 + server/db/schema.sql | 265 +++ server/ingest.js | 18 + server/model/permissions/permissions.db.js | 459 +++++ server/model/permissions/permissions.model.js | 356 ++++ server/permSync.js | 342 ++++ server/router/admin/permissions.controller.js | 424 ++++ server/router/admin/permissions.router.js | 184 ++ server/router/admin/rust.router.js | 5 + server/router/admin/usersRust.controller.js | 133 +- server/router/admin/usersRust.router.js | 60 +- server/scripts/swaggerFragment.js | 1 + server/sidecarClient.js | 39 +- server/swagger/doc.js | 231 +++ server/test/catalogue.test.js | 7 +- server/test/identityRoutes.test.js | 9 +- server/test/permissions.test.js | 285 +++ swagger-fragment.json | 1737 +++++++++++++++++ 27 files changed, 5515 insertions(+), 21 deletions(-) create mode 100644 client/src/routes/admin/Permissions.jsx create mode 100644 server/model/permissions/permissions.db.js create mode 100644 server/model/permissions/permissions.model.js create mode 100644 server/permSync.js create mode 100644 server/router/admin/permissions.controller.js create mode 100644 server/router/admin/permissions.router.js create mode 100644 server/test/permissions.test.js diff --git a/ci/bundle.json b/ci/bundle.json index a38c7af..6ecfb44 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -35,6 +35,7 @@ "ingest.js", "model", "package.json", + "permSync.js", "router", "sidecarClient.js" ], diff --git a/client/src/api.js b/client/src/api.js index 429c8b1..5947fe7 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -103,6 +103,50 @@ export const admin = { req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }), } +// ── admin · permissions (R2) ────────────────────────────────────────────── +// +// The authoring surface. Every call here writes to the SITE, and none of them +// reaches a game server — the mirror's own loop does that on its own cadence. +// `sync` is the exception and says so in its name: it runs the pass now and +// answers with what each server reported, which is the only call on this screen +// that can be slow or fail because a game host is down. +// +// A write is followed by a re-read rather than a local edit of the model: what +// the screen is showing is partly the game's answer, and the honest way to learn +// the new one is to ask. +export const adminPermissions = { + overview: () => req('/admin/rust/permissions'), + catalogue: () => req('/admin/rust/permissions/catalogue'), + + saveGroup: (name, body) => + req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }), + deleteGroup: (name) => + req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }), + + addMember: (name, username) => + req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, { + method: 'POST', + body: { username }, + }), + removeMember: (name, userId) => + req( + `/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`, + { method: 'DELETE' }, + ), + + grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }), + revoke: (id) => + req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + adoptDrift: (id) => + req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }), + revokeDrift: (id) => + req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }), + + sync: (serverId = null) => + req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }), +} + // ── the admin.users.detail extension slot ───────────────────────────────── // // The client half of R13's first slot. Core hands the component a `userId` and @@ -118,8 +162,34 @@ export const adminUserLinks = { }), } +// The same panel's phase 7 half: what this person may do in game. The id in the +// path is the one the slot handed the component, so these send `userId` rather +// than a name — the screen already knows who it is looking at. +export const adminUserPermissions = { + list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`), + grant: (userId, body) => + req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, { + method: 'POST', + body, + }), + revoke: (userId, grantId) => + req( + `/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`, + { method: 'DELETE' }, + ), +} + // Exported for the rare caller that needs the base itself — an ``, a // download link, an EventSource. Reach for `request` first. export { BASE, query } -export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE } +export default { + servers, + playerServers, + playerLinks, + admin, + adminPermissions, + adminUserLinks, + adminUserPermissions, + BASE, +} diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 7fb8b18..2a1c57e 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -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: }, { path: 'servers/:id', element: }, ], player: [{ path: '', element: }], + admin: [{ path: '', element: }], }) // ── 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 diff --git a/client/src/icons.jsx b/client/src/icons.jsx index 941eb3e..4019cd2 100644 --- a/client/src/icons.jsx +++ b/client/src/icons.jsx @@ -46,4 +46,20 @@ export const IconLink = () => ( ) -export default { IconLink } +/** + * A key — the admin sidebar's row for the permission mirror. + * + * Core's admin groups are labelled by subject and drawn with glyphs of the same + * weight, so this is the same 16px frame as the portal's. A key rather than a + * shield: a shield is protection from something, and this row is about handing + * somebody the right to do something. + */ +export const IconKey = () => ( + + + + + +) + +export default { IconLink, IconKey } diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx new file mode 100644 index 0000000..c19b09b --- /dev/null +++ b/client/src/routes/admin/Permissions.jsx @@ -0,0 +1,617 @@ +// ── Admin · Rust · Permissions ──────────────────────────────────────────── +// +// R2's authoring surface, and this module's first admin page. +// +// **What is on it is decided by what an operator can get wrong**, rather than by +// what the tables contain. Four states are invisible from the game and from a +// list of grants, and every one of them looks exactly like success: +// +// • a grant against somebody who has linked no Steam account — authored, +// stored, pushed nowhere; +// • a permission no loaded plugin has registered — the grant lands silently +// nowhere, because `GrantUserPermission` no-ops for an unregistered name; +// • a group member who has never connected — the store has no user record to +// put in a group yet, and the membership waits for their first connection; +// • a server whose last sync failed — the site is authoritative and the game +// has not heard it. +// +// So each of those is a sentence on this page rather than a number in a report. +// +// The screen never writes to a game. Every button here writes to the site and +// the mirror's loop reconciles within seconds — except *Sync now*, which runs +// that pass immediately because an operator who has just changed something +// should not have to trust a timer to find out that a host is unreachable. + +import { useCallback, useState } from 'react' + +import { ErrorState, Loading, useAsync } from '../../core.js' +import { ago } from '../../lib/format.js' +import api from '../../api.js' + +const FLEET = '*' + +/** Shared furniture. The kit is nine exports and none of them is a table. */ +function Card({ title, subtitle, children, actions }) { + return ( +
    +
    +

    + {title} +

    + {subtitle && ( + + {subtitle} + + )} + + {actions} +
    + {children} +
    + ) +} + +function Row({ children, muted = false }) { + return ( +
    + {children} +
    + ) +} + +function Warn({ children }) { + return ( +

    + {children} +

    + ) +} + +function Scope({ value }) { + return ( + + {value === FLEET ? 'every server' : value} + + ) +} + +/** + * One server's mirror state. + * + * `unresolved` and `pending` are rendered as sentences rather than counts + * because each is a different problem with a different fix, and both are + * invisible everywhere else on this page. + */ +function ServerState({ row, onSync, busy }) { + const report = row.report || {} + const unresolved = report.unresolved || [] + const pending = report.pending || [] + + return ( +
    +
    + + {row.serverId} + + + {row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'} + + + {row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'} + + + +
    + + {row.error && ( +

    + {row.error} +

    + )} + + {unresolved.length > 0 && ( + + {unresolved.join(', ')} — no plugin loaded on this server has registered{' '} + {unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '} + {unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when + the plugin is back. + + )} + + {pending.length > 0 && ( + + {pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a + first connection — this server has never seen those players, so it has no account to put + in a group yet. + + )} +
    + ) +} + +/** A hand edit, with the two answers to it. */ +function DriftRow({ row, onAdopt, onRevoke, busy }) { + const subject = row.username ? `${row.username} (${row.subject})` : row.subject + + return ( + + + {row.object}{' '} + + {row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '} + {row.serverId} · seen {ago(row.firstSeen)} + + + + + + ) +} + +/** + * The memberships the game could not place yet, as `steamId:group`. + * + * Read out of each server's own report, because it is the only thing that knows: + * a member who has never connected to a server has no user record there to put + * in a group (§12.2 rule 4), and from every other angle they look like a member. + * The server strip says how many; this is what puts it next to the person. + */ +function pendingSet(servers) { + const pending = new Map() + + for (const server of servers) { + for (const entry of (server.report && server.report.pending) || []) { + if (!pending.has(entry)) pending.set(entry, []) + pending.get(entry).push(server.serverId) + } + } + + return pending +} + +function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) { + const [busy, setBusy] = useState(false) + const [member, setMember] = useState('') + const [permission, setPermission] = useState('') + + const act = async (fn) => { + setBusy(true) + setError('') + try { + await fn() + await onChanged() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + const save = (permissions) => + act(() => + api.adminPermissions.saveGroup(group.name, { + title: group.title, + rank: group.rank, + scope: group.scope, + permissions, + }), + ) + + return ( + {group.name} · } + actions={ + + } + > +
    Permissions
    + {group.permissions.length === 0 && ( +

    + This group carries nothing, so being in it does nothing. +

    + )} + {group.permissions.map((perm) => ( + + {perm} + {!catalogue.some((entry) => entry.permission === perm) && ( + + no server has registered this + + )} + + + ))} + +
    { + event.preventDefault() + if (!permission.trim()) return + save([...group.permissions, permission.trim().toLowerCase()]) + setPermission('') + }} + > + setPermission(event.target.value)} + style={{ flex: 1 }} + /> + +
    + +
    + Members +
    + {group.members.length === 0 && ( +

    + Nobody is in this group. +

    + )} + {group.members.map((m) => { + const waiting = m.accounts + .map((account) => pending.get(`${account.steamId}:${group.name}`)) + .filter(Boolean) + .flat() + + return ( + + + {m.username} + {m.accounts.length > 0 ? ( + + {' '} + · {m.accounts.map((a) => a.name || a.steamId).join(', ')} + + ) : ( + + {' '} + · has linked no Steam account, so this reaches nobody + + )} + {waiting.length > 0 && ( + + {' '} + · waiting on their first connection to {[...new Set(waiting)].join(', ')} + + )} + + + + ) + })} + +
    { + event.preventDefault() + if (!member.trim()) return + act(() => api.adminPermissions.addMember(group.name, member.trim())) + setMember('') + }} + > + setMember(event.target.value)} + style={{ flex: 1 }} + /> + +
    + + {servers.length > 1 && group.scope !== FLEET && ( +

    + This group exists on {group.scope} only. The other servers never receive it. +

    + )} +
    + ) +} + +export default function Permissions() { + const [reloads, setReloads] = useState(0) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [form, setForm] = useState({ name: '', title: '', scope: FLEET }) + const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET }) + + const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads]) + const reload = useCallback(() => setReloads((n) => n + 1), []) + + const act = async (fn) => { + setBusy(true) + setError('') + try { + await fn() + reload() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + if (loadError) return + if (!data) return + + const servers = data.servers || [] + + return ( +
    + {/* No heading of our own: core's admin chrome already draws the route's + title above the page, and a second one is the same words twice. */} +

    + This site is the author of record. Groups and grants written here are pushed into each + server’s own permission store, so every plugin that checks a permission honours them — and a + wipe does not lose them, because they are re-pushed when the server comes back. +

    + + {/* The option source, shared by both forms. A datalist rather than a select: + a name that no server has registered is still authorable — the plugin + may simply not be loaded right now — and the warning beside it is the + honest treatment, where a closed list would be a refusal. */} + + {(data.catalogue || []).map((entry) => ( + + + {error && ( +

    + {error} +

    + )} + + act(() => api.adminPermissions.sync())}> + Sync all + + } + > + {servers.length === 0 && ( +

    + No servers are configured yet, so nothing written here reaches a game. +

    + )} + {servers.map((row) => ( + act(() => api.adminPermissions.sync(id))} + /> + ))} +
    + + {(data.drift || []).length > 0 && ( + +

    + Nothing here is undone automatically. Adopt records it as the site’s + own, so it survives the next wipe; Revoke removes it from the game on + the next sync. +

    + {data.drift.map((row) => ( + act(() => api.adminPermissions.adoptDrift(d.id))} + onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))} + /> + ))} +
    + )} + + + {(data.grants || []).length === 0 && ( +

    + Nobody holds a permission of their own yet. +

    + )} + {(data.grants || []).map((row) => ( + + + {row.username} · {row.permission}{' '} + + {row.accounts.length === 0 && ( + + {' '} + · has linked no Steam account, so this reaches nobody + + )} + {/* The same warning the group's permission list carries, and it + matters more here: a grant naming a permission nothing has + registered is the failure the plugin's pre-check exists for, + and it is invisible on this row without it. */} + {!(data.catalogue || []).some((entry) => entry.permission === row.permission) && ( + + {' '} + · no server has registered this permission + + )} + {row.source !== 'admin' && ( + · {row.source} + )} + + + + ))} + +
    { + event.preventDefault() + if (!grant.username.trim() || !grant.permission.trim()) return + act(() => + api.adminPermissions.grant({ + username: grant.username.trim(), + permission: grant.permission.trim().toLowerCase(), + scope: grant.scope, + }), + ) + setGrant({ username: '', permission: '', scope: FLEET }) + }} + > + setGrant({ ...grant, username: event.target.value })} + style={{ flex: '1 1 160px' }} + /> + setGrant({ ...grant, permission: event.target.value })} + style={{ flex: '1 1 160px' }} + /> + + +
    +
    + + {(data.groups || []).map((group) => ( + + ))} + + +
    { + event.preventDefault() + if (!form.name.trim()) return + act(() => + api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), { + title: form.title.trim() || form.name.trim(), + scope: form.scope, + permissions: [], + }), + ) + setForm({ name: '', title: '', scope: FLEET }) + }} + > + setForm({ ...form, name: event.target.value })} + style={{ flex: '1 1 140px' }} + /> + setForm({ ...form, title: event.target.value })} + style={{ flex: '1 1 140px' }} + /> + + +
    +

    + A group is created in each in-scope game as a real group, so plugins that read group + membership see it. A member who has never connected to a server joins it there on their + first connection — a direct grant reaches them straight away, which is the difference + worth knowing when somebody is waiting. +

    +
    +
    + ) +} diff --git a/client/src/routes/admin/UserRustSections.jsx b/client/src/routes/admin/UserRustSections.jsx index 3313965..1d167d6 100644 --- a/client/src/routes/admin/UserRustSections.jsx +++ b/client/src/routes/admin/UserRustSections.jsx @@ -113,11 +113,131 @@ function LinkPanel({ userId, link, onRemoved }) { ) } +/** + * Phase 7's half of the panel: what this person may do in game. + * + * It renders whenever they hold anything, INCLUDING when they have linked no + * Steam account — which is the one case worth going out of the way for. A grant + * against an unlinked person is authored, stored, pushed nowhere, and identical + * to a working one everywhere except here. + */ +function PermissionsPanel({ userId, data, onChanged }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [permission, setPermission] = useState('') + + const act = async (fn) => { + setBusy(true) + setError('') + try { + await fn() + await onChanged() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + if (!data) return null + + const nothing = data.groups.length === 0 && data.grants.length === 0 + + return ( +
    +
    + Permissions +
    + + {nothing && ( +

    + Nothing granted. +

    + )} + + {data.groups.map((group) => ( +
    + {group.title || group.name}{' '} + + group · {group.scope === '*' ? 'every server' : group.scope} + {group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'} + +
    + ))} + + {data.grants.map((row) => ( +
    + + {row.permission}{' '} + + {row.scope === '*' ? 'every server' : row.scope} + {row.source !== 'admin' ? ` · ${row.source}` : ''} + + + +
    + ))} + + {!nothing && data.reaches.length === 0 && ( +

    + This account has linked no Steam id, so none of it reaches a game yet. It will apply by + itself when they link. +

    + )} + +
    { + event.preventDefault() + if (!permission.trim()) return + act(() => + api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }), + ) + setPermission('') + }} + > + setPermission(event.target.value)} + style={{ flex: 1 }} + /> + +
    + + {error && ( +

    + {error} +

    + )} +
    + ) +} + export default function UserRustSections({ userId }) { // Core's `useAsync` has no refresh, so a counter in the deps is how this // re-reads after its own write (the same shape the player page uses). const [reloads, setReloads] = useState(0) const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads]) + const { data: permissions } = useAsync( + () => api.adminUserPermissions.list(userId), + [userId, reloads], + ) const reload = useCallback(() => setReloads((n) => n + 1), []) // No `Loading` and no `ErrorState`, deliberately. This is a section inside @@ -125,7 +245,15 @@ export default function UserRustSections({ userId }) { // have nothing to do with is worse than a section that appears when it has // something, and a failure here must not replace core's own user detail with an // error card. - if (!data || data.links.length === 0) return null + // **Both reads decide whether this section exists**, and the second one is the + // reason. A browser walk found it: a person can hold permissions and have + // linked no Steam account — which is exactly the state an operator most needs + // to see, because it is the one that reaches nobody — and a section gated on + // links alone hides it completely. + const holdsSomething = + permissions && (permissions.groups.length > 0 || permissions.grants.length > 0) + + if (!data || (data.links.length === 0 && !holdsSomething)) return null return (
    @@ -135,13 +263,22 @@ export default function UserRustSections({ userId }) { {data.links.map((link) => ( ))} - -

    - A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is - recorded in the activity log — it is the way back for a player who linked the wrong account - and cannot reach it in game. -

    + {data.links.length > 0 && ( +

    + A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is + recorded in the activity log — it is the way back for a player who linked the wrong + account and cannot reach it in game. +

    + )} + + {/* Inside the same section rather than beside it: "who is this in game" + and "what may they do there" are one question asked twice, and an + operator reading a support ticket has both in front of them. The note + above belongs to the links, so it sits with them rather than under + the panel it would otherwise appear to describe. */} + +
    ) } diff --git a/routes.manifest.json b/routes.manifest.json index 18657bd..e1814e3 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -1,6 +1,21 @@ { "$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.", "routes": [ + { + "method": "DELETE", + "path": "/api/v1/admin/rust/permissions/grants/:id", + "tier": "public" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/rust/permissions/groups/:name", + "tier": "public" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId", + "tier": "public" + }, { "method": "DELETE", "path": "/api/v1/admin/rust/servers/:id", @@ -11,11 +26,26 @@ "path": "/api/v1/admin/users/:id/rust/links/:steamId", "tier": "public" }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId", + "tier": "public" + }, { "method": "DELETE", "path": "/api/v1/player/rust/links/:steamId", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/rust/permissions", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/rust/permissions/catalogue", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/admin/rust/servers", @@ -26,6 +56,11 @@ "path": "/api/v1/admin/users/:id/rust/links", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/rust/permissions", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/player/rust/links", @@ -66,16 +101,51 @@ "path": "/api/v1/public/rust/servers/:id/wipes", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/drift/:id/adopt", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/drift/:id/revoke", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/grants", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/groups/:name/members", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/sync", + "tier": "public" + }, { "method": "POST", "path": "/api/v1/admin/rust/servers/:id/test", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/admin/users/:id/rust/permissions/grants", + "tier": "public" + }, { "method": "POST", "path": "/api/v1/player/rust/link", "tier": "public" }, + { + "method": "PUT", + "path": "/api/v1/admin/rust/permissions/groups/:name", + "tier": "public" + }, { "method": "PUT", "path": "/api/v1/admin/rust/servers/:id", diff --git a/server/boot.js b/server/boot.js index 945cf5f..b4eb58d 100644 --- a/server/boot.js +++ b/server/boot.js @@ -44,6 +44,7 @@ const core = require('./core') const db = require('./model/servers/servers.db') const eventsDb = require('./model/events/events.db') const ingest = require('./ingest') +const permSync = require('./permSync') const servers = require('./model/servers/servers.model') const sidecar = require('./sidecarClient') @@ -190,6 +191,11 @@ async function prune() { async function onBoot() { await refresh() + // The permission mirror owns its own loop and its own cadence (see + // `permSync.js`). It is started rather than run here: a first pass would write + // to every configured game server before the website had finished booting, and + // nothing about R2 is urgent enough to delay a listener for. + permSync.start() refreshTimer = setInterval(refresh, REFRESH_MS) ingestTimer = setInterval(ingestAll, INGEST_MS) pruneTimer = setInterval(prune, PRUNE_MS) @@ -200,7 +206,7 @@ async function onBoot() { if (timer && typeof timer.unref === 'function') timer.unref() } - log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS }) + log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS }) } /** @@ -212,6 +218,8 @@ async function onBoot() { * rather than cancelled, since nothing can stop a promise that is still running. */ async function onShutdown() { + permSync.stop() + for (const timer of [refreshTimer, ingestTimer, pruneTimer]) { if (timer) clearInterval(timer) } diff --git a/server/catalogue.js b/server/catalogue.js index cf255bf..de3c6ba 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -76,6 +76,10 @@ const STAFF_KINDS = Object.freeze([ // about somebody's identity, not about what happened on the server. 'account.link.requested', 'account.unlinked', + // Protocol 4. Who holds which privilege in game, and the fact that somebody + // changed it by hand — a question about a person's standing and about an + // operator's own console, neither of which is a public page's business. + 'perm.drift', ]) /** Every kind protocol 3 defines. */ diff --git a/server/db/purge.sql b/server/db/purge.sql index bb0e96d..f48674b 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -19,6 +19,17 @@ -- it knows this module registered, because it is the side that knows which -- registrant owned what. +-- Phase 7. Children before parents: every one of these carries a foreign key +-- into `rust_servers`, `users` or `rust_perm_groups`. +DROP TABLE IF EXISTS rust_perm_catalogue; +DROP TABLE IF EXISTS rust_perm_sync; +DROP TABLE IF EXISTS rust_perm_revocations; +DROP TABLE IF EXISTS rust_perm_drift; +DROP TABLE IF EXISTS rust_perm_pushed; +DROP TABLE IF EXISTS rust_perm_grants; +DROP TABLE IF EXISTS rust_perm_group_members; +DROP TABLE IF EXISTS rust_perm_group_permissions; +DROP TABLE IF EXISTS rust_perm_groups; DROP TABLE IF EXISTS rust_account_links; DROP TABLE IF EXISTS rust_ingest_cursor; DROP TABLE IF EXISTS rust_presence; diff --git a/server/db/schema.sql b/server/db/schema.sql index 3248d5f..da2b04b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -335,6 +335,271 @@ CREATE TABLE IF NOT EXISTS rust_account_links ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ── Site-owned permissions (phase 7, R2) ────────────────────────────────── +-- +-- The website is the author of record for who may do what in game, and the +-- framework's own permission store is an ENFORCEMENT CACHE. That is one +-- sentence with three consequences, and the tables below are shaped by them: +-- +-- • Every third-party plugin honours a site grant with no adapter, because +-- they all already call `UserHasPermission`. Nothing here is read by the +-- game directly; it is pushed into the store the game already consults. +-- • A wipe stops being a data-loss event. The game forgets and the site does +-- not, so the next sync puts it all back. +-- • A hand edit is REPORTED, never silently overwritten (D31). Which means +-- the site has to be able to tell a grant it made from one somebody typed +-- at a console — and that is a fact only the site can hold, because the +-- store records who granted a permission nowhere. +-- +-- ── A grant is against a WEBSITE USER (D28) ─────────────────────────────── +-- +-- Not against a Steam id, though a Steam id is what reaches the game. The site +-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever +-- earned them, and an account unlinked from a person takes their privileges +-- with it. The Steam ids are resolved from `rust_account_links` at push time, +-- so a player who links a second account gets what they hold on both — which is +-- the honest reading of "this person may do this". +-- +-- A user with no linked account is authored against perfectly well and simply +-- reaches nobody until they link. That is visible on the admin screen rather +-- than silent, because a grant that reaches nothing looks identical to a grant +-- that worked from every other angle. +-- +-- ── Scope (D29) ─────────────────────────────────────────────────────────── +-- +-- Every authored row carries one: a server id, or `*` for the whole fleet. The +-- game stores permissions per server (each has its own store), an operator +-- running a modded server and a vanilla one will not want one set on both, and +-- a single-server community never has to think about it. + + +-- ── Groups ──────────────────────────────────────────────────────────────── +-- +-- Mirrored into the game as REAL groups (D30) rather than flattened into +-- per-player grants. Third-party plugins read group membership, BetterChat's +-- group API (R15, phase 17) has something to hang on, and an operator reading +-- `oxide.show groups` sees what the website shows. +-- +-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does +-- not go away: **a player the store has never seen cannot be put in a group**, +-- while a direct grant to the same id works immediately. The sync reports those +-- members as pending and the membership lands on their first connection. +-- +-- The name is the primary key, fleet-wide, even though the row carries a scope: +-- one `vip` on the site is one `vip` in the game, pushed to the servers its +-- scope names. Two groups of the same name with different scopes would be two +-- definitions of one name in every store that received both. +CREATE TABLE IF NOT EXISTS rust_perm_groups ( + name VARCHAR(64) NOT NULL PRIMARY KEY, + title VARCHAR(120) NOT NULL DEFAULT '', + rank INT NOT NULL DEFAULT 0, + scope VARCHAR(64) NOT NULL DEFAULT '*', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +-- What each group carries. A row per permission rather than a list on the group +-- for the ordinary reason: "which groups grant kits.vip" is the question an +-- operator asks when they are about to remove a plugin, and that is a WHERE +-- clause here and a scan of every row in the other shape. +CREATE TABLE IF NOT EXISTS rust_perm_group_permissions ( + group_name VARCHAR(64) NOT NULL, + permission VARCHAR(128) NOT NULL, + PRIMARY KEY (group_name, permission), + CONSTRAINT fk_rust_perm_group_permissions_group + FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE +); + + +-- Who is in each group — by website user, like every other authored row. +-- +-- `added_by` is an admin's user id and deliberately carries NO foreign key: a +-- staff member's account being deleted must not delete the record of what they +-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody". +-- The activity log is the audit trail; this column is a convenience beside it. +CREATE TABLE IF NOT EXISTS rust_perm_group_members ( + group_name VARCHAR(64) NOT NULL, + user_id INT NOT NULL, + added_by INT NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (group_name, user_id), + KEY idx_rust_perm_members_user (user_id), + CONSTRAINT fk_rust_perm_members_group + FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE, + CONSTRAINT fk_rust_perm_members_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + + +-- ── Direct grants ───────────────────────────────────────────────────────── +-- +-- A permission held by one person, without a group. It is not a lesser version +-- of membership: it is the shape that reaches a player who has never connected +-- to that server, which is exactly what an entitlement earned on the website at +-- three in the morning has to do (R16). +-- +-- `source` is why this table does not need changing in phase 13. Every later +-- author — an event action granting the right to redeem a kit, a lease handing +-- out a weekend group — writes a row here with its own source rather than a +-- store of its own, so there is one answer to "why does this player have this" +-- and one place the push reads. +CREATE TABLE IF NOT EXISTS rust_perm_grants ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + permission VARCHAR(128) NOT NULL, + scope VARCHAR(64) NOT NULL DEFAULT '*', + source VARCHAR(32) NOT NULL DEFAULT 'admin', + note VARCHAR(255) NULL, + granted_by INT NULL, + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope), + KEY idx_rust_perm_grant_user (user_id), + CONSTRAINT fk_rust_perm_grants_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + + +-- ── What this site has actually put in each game ────────────────────────── +-- +-- The site's memory of its own authorship, one row per thing it has confirmed +-- into one server's store. It is the table that makes D31 possible at all. +-- +-- Three sets, and every interesting question is the difference between two of +-- them: +-- +-- desired − pushed what to apply +-- pushed − desired what to RETIRE, because the site put it there and has +-- since withdrawn it +-- present − desired drift: somebody else put it there +-- +-- Without the middle row a withdrawn grant is indistinguishable from a hand +-- edit, and those two have opposite correct answers. Inferring it from absence +-- is the mistake this table exists to prevent. +-- +-- It is keyed by Steam id rather than by user, because it records what is in the +-- GAME, and the game has never heard of a website account. Unlinking an account +-- therefore leaves its row here until the next sync retires it — which is the +-- correct behaviour and would be impossible to express keyed the other way. +CREATE TABLE IF NOT EXISTS rust_perm_pushed ( + server_id VARCHAR(64) NOT NULL, + -- `grant` | `member` | `group-permission` | `group` + kind VARCHAR(24) NOT NULL, + -- a Steam id, or a group name + subject VARCHAR(64) NOT NULL, + -- a permission, a group name, or '' for the existence of a group + object VARCHAR(128) NOT NULL, + pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (server_id, kind, subject, object), + CONSTRAINT fk_rust_perm_pushed_server + FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE +); + + +-- ── Drift ───────────────────────────────────────────────────────────────── +-- +-- What a sync found in a server's store that the site did not author, within +-- the namespace the site claims. Rows appear and disappear with the report: +-- this is the CURRENT difference, not a history of differences, and a hand edit +-- that somebody has since removed should stop being on the screen. +-- +-- Nothing here is ever removed from the game by the sync itself. An operator +-- typing `oxide.grant` during an incident is drift, not an error, and the two +-- answers offered to them — adopt it, or revoke it — are both a person's +-- decision. +CREATE TABLE IF NOT EXISTS rust_perm_drift ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + server_id VARCHAR(64) NOT NULL, + kind VARCHAR(24) NOT NULL, + subject VARCHAR(64) NOT NULL, + object VARCHAR(128) NOT NULL, + first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object), + CONSTRAINT fk_rust_perm_drift_server + FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE +); + + +-- ── Removing something the site never put there ─────────────────────────── +-- +-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole +-- point of a drift row is that it was never pushed. It cannot go through the +-- authored tables either: a foreign grant often names a Steam id that belongs +-- to no website account at all, and there is no user to author it against. +-- +-- So a revoke is its own instruction with its own lifetime: queued by a person, +-- carried in the next sync's retire list, and deleted once a report says the +-- game no longer has it. A server that is offline keeps the instruction until +-- it comes back, which is the behaviour an operator expects from a website that +-- claims to be the author of record. +CREATE TABLE IF NOT EXISTS rust_perm_revocations ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + server_id VARCHAR(64) NOT NULL, + kind VARCHAR(24) NOT NULL, + subject VARCHAR(64) NOT NULL, + object VARCHAR(128) NOT NULL, + requested_by INT NULL, + requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object), + CONSTRAINT fk_rust_perm_revocations_server + FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE +); + + +-- ── The state of the mirror, per server ─────────────────────────────────── +-- +-- One row per configured server: whether its store currently matches what the +-- site authors, when that was last true, and what the last report said. +-- +-- `dirty` is how everything that should provoke a sync says so without knowing +-- anything about syncing: an admin writing a grant, a drift hook firing in the +-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and +-- every other part of the module owns WHETHER. +-- +-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop +-- that pushed the whole set every tick would work and would also write to six +-- game servers every thirty seconds for ever; comparing a hash costs one query +-- and skips the round trip when nothing has changed. The periodic audit below +-- is what keeps that from being a way to never notice drift. +CREATE TABLE IF NOT EXISTS rust_perm_sync ( + server_id VARCHAR(64) NOT NULL PRIMARY KEY, + -- `pending` | `ok` | `failed` + state VARCHAR(24) NOT NULL DEFAULT 'pending', + dirty TINYINT(1) NOT NULL DEFAULT 1, + desired_hash VARCHAR(64) NULL, + synced_hash VARCHAR(64) NULL, + boot_id VARCHAR(64) NULL, + wipe_id VARCHAR(48) NULL, + last_attempt_at DATETIME NULL, + last_ok_at DATETIME NULL, + report LONGTEXT NULL, + error VARCHAR(191) NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_rust_perm_sync_server + FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE +); + + +-- ── What each server's plugins have registered ──────────────────────────── +-- +-- The option source the authoring form offers (D33), cached from the live read +-- so that opening the form is not six round trips to six game hosts. +-- +-- It is a cache of a fact that changes when an operator loads a plugin, and it +-- is refreshed on every sync — which is also why a name that has stopped being +-- registered disappears from the form rather than lingering as a choice that +-- silently does nothing. +CREATE TABLE IF NOT EXISTS rust_perm_catalogue ( + server_id VARCHAR(64) NOT NULL, + permission VARCHAR(128) NOT NULL, + seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (server_id, permission), + CONSTRAINT fk_rust_perm_catalogue_server + FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE +); + + -- ── Changes to tables that already shipped ──────────────────────────────── -- -- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS` diff --git a/server/ingest.js b/server/ingest.js index 7e878d5..8256293 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -35,6 +35,7 @@ const core = require('./core') const db = require('./model/events/events.db') const links = require('./model/links/links.model') +const permissionsDb = require('./model/permissions/permissions.db') const sidecar = require('./sidecarClient') const log = core.logger('ingest') @@ -173,6 +174,23 @@ async function apply(serverId, item) { await db.touchPlayer(frame.steamId, frame.name || null) break + // ── Protocol 4: somebody changed the permission store, and it was not us ── + // + // The plugin raises this only for writes it did not make itself — its own + // sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What + // arrives here is therefore a hand edit, a console command, or another + // plugin granting something. + // + // **It is a reason to reconcile, not the reconciliation.** This frame cannot + // say whether the change is foreign: only the desired set can, and that + // comparison happens in the sync. So the server is marked dirty and the next + // tick produces the authoritative answer — which means a hook that stops + // firing on a framework upgrade costs latency and nothing else. The audit + // interval finds the same drift within fifteen minutes either way. + case 'perm.drift': + await permissionsDb.markDirty(serverId) + break + default: // Stored, not counted. Moderation frames, the server lifecycle, and // anything a newer protocol sends that this build does not understand. diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js new file mode 100644 index 0000000..de97b05 --- /dev/null +++ b/server/model/permissions/permissions.db.js @@ -0,0 +1,459 @@ +// ── SQL for the permission mirror, and nothing else ─────────────────────── +// +// The tables this file reads are described at length in `db/schema.sql`; what +// matters here is which of them is authoritative for what, because four of the +// eight look similar and answer completely different questions: +// +// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`, +// `rust_perm_grants` — what an operator (and later an event) says +// should be true. Keyed by WEBSITE USER (D28). +// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's +// store. Keyed by STEAM ID, because it records what is in the game +// and the game has never heard of a website account. +// FOUND `rust_perm_drift` — what a sync found that the site did not +// author. Replaced whole by each report: it is the current +// difference, not a history of differences. +// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put +// it there. The only way to act on drift, since a foreign grant +// often names a Steam id no website account holds. +// +// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js` +// here. Bulk writes are batched into one statement with a generated placeholder +// list rather than looped, because a fleet-wide sync writes hundreds of rows and +// a round trip each is how a boot tick becomes a second long. + +const core = require('../../core') + +const GROUPS = 'rust_perm_groups' +const GROUP_PERMISSIONS = 'rust_perm_group_permissions' +const GROUP_MEMBERS = 'rust_perm_group_members' +const GRANTS = 'rust_perm_grants' +const PUSHED = 'rust_perm_pushed' +const DRIFT = 'rust_perm_drift' +const REVOCATIONS = 'rust_perm_revocations' +const SYNC = 'rust_perm_sync' +const CATALOGUE = 'rust_perm_catalogue' +const LINKS = 'rust_account_links' +const SERVERS = 'rust_servers' + +/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */ +function placeholders(rows, width) { + return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',') +} + +// ---- the authored set ---- + +async function listGroups() { + return core.query( + `SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt + FROM ${GROUPS} + ORDER BY \`rank\` DESC, name ASC`, + ) +} + +async function getGroup(name) { + const rows = await core.query( + `SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`, + [name], + ) + + return rows[0] || null +} + +/** + * Create or update one group. + * + * `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the + * same screen is not a race worth losing a title over, and the row's identity is + * its name either way. + */ +async function upsertGroup({ name, title, rank, scope }) { + await core.query( + `INSERT INTO ${GROUPS} (name, title, \`rank\`, scope) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`), + scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`, + [name, title, rank, scope], + ) +} + +async function deleteGroup(name) { + const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name]) + return Number(result.affectedRows || 0) > 0 +} + +async function listGroupPermissions() { + return core.query( + `SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`, + ) +} + +/** Replace a group's permission list whole. The form edits a list, so the write is a list. */ +async function setGroupPermissions(name, permissions) { + await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name]) + + if (!permissions.length) return + + await core.query( + `INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission) + VALUES ${placeholders(permissions, 2)}`, + permissions.flatMap((permission) => [name, permission]), + ) +} + +/** + * Every membership, with the member's Steam accounts joined on. + * + * One query rather than a membership read plus a link read per member: the admin + * screen renders both together and the push needs both together, and a fleet's + * worth of members is one round trip either way. + */ +async function listGroupMembers() { + return core.query( + `SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt, + u.username, l.steam_id AS steamId, p.name AS playerName + FROM ${GROUP_MEMBERS} m + JOIN users u ON u.id = m.user_id + LEFT JOIN ${LINKS} l ON l.user_id = m.user_id + LEFT JOIN rust_players p ON p.steam_id = l.steam_id + ORDER BY m.group_name ASC, u.username ASC`, + ) +} + +async function addGroupMember(groupName, userId, addedBy) { + await core.query( + `INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`, + [groupName, userId, addedBy], + ) +} + +async function removeGroupMember(groupName, userId) { + const result = await core.query( + `DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`, + [groupName, userId], + ) + + return Number(result.affectedRows || 0) > 0 +} + +/** + * Every direct grant, with the holder's accounts joined on. + * + * `username` is on the row because a grant with no linked Steam account still + * has to be listable and nameable — that state is the one the admin screen most + * needs to show, since it looks exactly like a working grant from every other + * angle and reaches nobody. + */ +async function listGrants({ userId = null } = {}) { + return core.query( + `SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note, + g.granted_at AS grantedAt, u.username, + l.steam_id AS steamId, p.name AS playerName + FROM ${GRANTS} g + JOIN users u ON u.id = g.user_id + LEFT JOIN ${LINKS} l ON l.user_id = g.user_id + LEFT JOIN rust_players p ON p.steam_id = l.steam_id + ${userId === null ? '' : 'WHERE g.user_id = ?'} + ORDER BY u.username ASC, g.permission ASC`, + userId === null ? [] : [userId], + ) +} + +async function getGrant(id) { + const rows = await core.query( + `SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`, + [id], + ) + + return rows[0] || null +} + +/** + * Add a grant, or leave the one that is already there alone. + * + * `INSERT IGNORE` against the unique key, and the return says which happened — + * the controller needs to tell "granted" from "they already had it" to write an + * honest activity row. + */ +async function insertGrant({ userId, permission, scope, source, note, grantedBy }) { + const result = await core.query( + `INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by) + VALUES (?, ?, ?, ?, ?, ?)`, + [userId, permission, scope, source, note, grantedBy], + ) + + return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId } +} + +async function deleteGrant(id) { + const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id]) + return Number(result.affectedRows || 0) > 0 +} + +/** + * One website account by name, for the authoring form. + * + * A form that made an operator type a numeric user id would be a form nobody + * could use, and the alternative — calling core's own admin user search from the + * client — would bind this module to the shape of a response the contract does + * not cover. Reading the `users` table is already what every join in this file + * does. + * + * Case-insensitive because the column's collation is: core stores usernames in a + * `_ci` collation and an exact-case lookup would refuse a name the site itself + * considers the same one. + */ +async function findUserByUsername(username) { + const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username]) + return rows[0] || null +} + +/** Which website user holds which Steam account. The join that turns an authored row into a push. */ +async function listLinks() { + return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`) +} + +// ---- what is actually out there ---- + +async function listPushed(serverId) { + return core.query( + `SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`, + [serverId], + ) +} + +async function addPushed(serverId, rows) { + if (!rows.length) return + + await core.query( + `INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object) + VALUES ${placeholders(rows, 4)}`, + rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]), + ) +} + +async function removePushed(serverId, rows) { + for (const row of rows) { + // eslint-disable-next-line no-await-in-loop + await core.query( + `DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`, + [serverId, row.kind, row.subject, row.object], + ) + } +} + +/** + * Replace one server's drift list with what the latest report found. + * + * Whole, rather than merged, and `first_seen` survives through the + * `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still + * answerable while "somebody has since undone it" removes the row. + */ +async function replaceDrift(serverId, rows) { + if (!rows.length) { + await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId]) + return + } + + await core.query( + `INSERT INTO ${DRIFT} (server_id, kind, subject, object) + VALUES ${placeholders(rows, 4)} + ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`, + rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]), + ) + + // Anything this report did NOT name is gone from the game, so it goes from + // here. Named explicitly rather than swept by timestamp: two syncs a second + // apart would make a timestamp window either delete live rows or keep dead + // ones, depending on the clock. + await core.query( + `DELETE FROM ${DRIFT} + WHERE server_id = ? + AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`, + [serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])], + ) +} + +async function listDrift() { + return core.query( + `SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object, + d.first_seen AS firstSeen, d.last_seen AS lastSeen, + l.user_id AS userId, u.username, p.name AS playerName + FROM ${DRIFT} d + LEFT JOIN ${LINKS} l ON l.steam_id = d.subject + LEFT JOIN users u ON u.id = l.user_id + LEFT JOIN rust_players p ON p.steam_id = d.subject + ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`, + ) +} + +async function getDrift(id) { + const rows = await core.query( + `SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`, + [id], + ) + + return rows[0] || null +} + +async function deleteDrift(id) { + await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id]) +} + +async function queueRevocation({ serverId, kind, subject, object, requestedBy }) { + await core.query( + `INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by) + VALUES (?, ?, ?, ?, ?)`, + [serverId, kind, subject, object, requestedBy], + ) +} + +async function listRevocations(serverId) { + return core.query( + `SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`, + [serverId], + ) +} + +async function deleteRevocations(ids) { + if (!ids.length) return + + await core.query( + `DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`, + ids, + ) +} + +// ---- the state of the mirror ---- + +/** + * One sync row per configured server, created on demand. + * + * A server added today has no row and must not therefore be skipped for ever, so + * the read inserts what is missing rather than the writer remembering to. + */ +async function ensureSyncRows() { + await core.query( + `INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`, + ) +} + +async function listSync() { + return core.query( + `SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash, + s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId, + s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt, + s.report, s.error + FROM ${SYNC} s + ORDER BY s.server_id ASC`, + ) +} + +/** + * Mark servers as needing a sync. + * + * `scope` is a server id or `*`; a fleet-wide change dirties every row, which is + * right: the set each server should hold has changed even if only one of them + * will notice a difference. + */ +async function markDirty(scope) { + if (!scope || scope === '*') { + await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`) + return + } + + await core.query( + `UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`, + [scope], + ) +} + +/** + * Record the outcome of one attempt. + * + * **`dirty` is cleared unconditionally, and that is safe because it is an + * optimisation rather than the truth.** Something may well have changed the + * authored set while this sync was in flight, and clearing the flag would then + * lose that change — except that the loop's real condition is + * `desired_hash != synced_hash`, recomputed from the tables on every tick. The + * flag only saves a hash comparison; the hash is what cannot be wrong. + * + * `last_ok_at` moves only on success, and it is passed rather than composed into + * the SQL so the statement is the same string every time. + */ +async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) { + const okAt = state === 'ok' ? new Date() : null + + await core.query( + `INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id, + last_attempt_at, last_ok_at, report, error, updated_at) + VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0, + desired_hash = VALUES(desired_hash), + synced_hash = VALUES(synced_hash), + boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id), + last_attempt_at = NOW(), + last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at), + report = VALUES(report), error = VALUES(error), + updated_at = NOW()`, + [serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error], + ) +} + +// ---- the option source ---- + +async function putCatalogue(serverId, permissions) { + await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId]) + + if (!permissions.length) return + + await core.query( + `INSERT IGNORE INTO ${CATALOGUE} (server_id, permission) + VALUES ${placeholders(permissions, 2)}`, + permissions.flatMap((permission) => [serverId, permission]), + ) +} + +async function listCatalogue() { + return core.query( + `SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`, + ) +} + +module.exports = { + GROUPS, + GRANTS, + PUSHED, + DRIFT, + listGroups, + getGroup, + upsertGroup, + deleteGroup, + listGroupPermissions, + setGroupPermissions, + listGroupMembers, + addGroupMember, + removeGroupMember, + listGrants, + getGrant, + insertGrant, + deleteGrant, + findUserByUsername, + listLinks, + listPushed, + addPushed, + removePushed, + replaceDrift, + listDrift, + getDrift, + deleteDrift, + queueRevocation, + listRevocations, + deleteRevocations, + ensureSyncRows, + listSync, + markDirty, + putSyncResult, + putCatalogue, + listCatalogue, +} diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js new file mode 100644 index 0000000..5412fd1 --- /dev/null +++ b/server/model/permissions/permissions.model.js @@ -0,0 +1,356 @@ +// ── The authored set, and what it means for one server ──────────────────── +// +// This file turns "what an operator wrote on the website" into "what one game +// server's store should contain", which is where four of phase 7's decisions +// actually live: +// +// D28 a grant is authored against a WEBSITE USER and resolved to every Steam +// id they have linked, here, at the moment of the push. +// D29 every authored row carries a scope — one server, or `*` for the fleet — +// and a server sees only what names it. +// D30 groups travel as groups. Membership is a separate wire fact from the +// permissions the group carries, because the game stores them separately +// and one of the two can fail on its own (§12.2 rule 4). +// D31 the difference between the desired set and what this site has already +// pushed is what gets retired. Anything else in the store is drift, and +// drift is reported rather than undone. +// +// Nothing here talks to a sidecar — `permSync.js` does that. The split is the +// usual one and earns its keep twice over here: the whole of the interesting +// logic is a pure function of four tables, so it is tested without a game, a +// sidecar, or a database. + +const crypto = require('node:crypto') + +const db = require('./permissions.db') + +/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */ +const FLEET = '*' + +/** + * Permission and group names, as both frameworks store them. + * + * Lowercased on the way in, because the store lowers them and a site that did + * not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own + * grant as drift for ever. + */ +function normaliseName(value) { + return String(value || '').trim().toLowerCase() +} + +/** Whether a scope reaches a server. */ +function inScope(scope, serverId) { + return scope === FLEET || scope === serverId +} + +/** + * Everything the authoring screen renders, in one read. + * + * Assembled here rather than in SQL because the shape is a tree — a group with + * its permissions and its members — and the alternative is either four round + * trips per group or one join that repeats every group row once per member. + */ +async function overview() { + const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([ + db.listGroups(), + db.listGroupPermissions(), + db.listGroupMembers(), + db.listGrants(), + db.listSync(), + db.listDrift(), + db.listCatalogue(), + ]) + + const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }])) + + for (const row of groupPermissions) { + const group = byGroup.get(row.groupName) + if (group) group.permissions.push(row.permission) + } + + // A member with two linked Steam accounts arrives as two rows from the join, + // and is one person on the screen — holding BOTH accounts, not the first one + // the join happened to return. The screen needs all of them: a membership is + // pushed per account, and it can be waiting on one while it landed on another. + const memberByKey = new Map() + + for (const row of members) { + const group = byGroup.get(row.groupName) + if (!group) continue + + const key = `${row.groupName}:${row.userId}` + let member = memberByKey.get(key) + + if (!member) { + member = { + userId: row.userId, + username: row.username, + accounts: [], + addedAt: row.addedAt, + } + memberByKey.set(key, member) + group.members.push(member) + } + + if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null }) + } + + return { + groups: [...byGroup.values()], + grants: collapseGrants(grants), + servers: sync.map(shapeSync), + drift, + catalogue: catalogueByPermission(catalogue), + } +} + +/** + * One row per grant, not one per linked account. + * + * The join in `listGrants` multiplies a grant by the holder's accounts, which is + * what the push wants and the opposite of what a screen wants. + */ +function collapseGrants(rows) { + const byId = new Map() + + for (const row of rows) { + const existing = byId.get(row.id) + + if (!existing) { + byId.set(row.id, { + id: row.id, + userId: row.userId, + username: row.username, + permission: row.permission, + scope: row.scope, + source: row.source, + note: row.note, + grantedAt: row.grantedAt, + accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [], + }) + + continue + } + + if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null }) + } + + return [...byId.values()] +} + +/** + * The sync row as a client reads it. + * + * `report` is stored as the JSON the game sent and parsed here rather than on the + * way in, so a report this build cannot read is a rendering problem on one + * screen instead of a write that failed. + */ +function shapeSync(row) { + let report = null + + if (row.report) { + try { + report = JSON.parse(row.report) + } catch { + report = null + } + } + + return { + serverId: row.serverId, + state: row.state, + dirty: Boolean(row.dirty), + inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok', + lastAttemptAt: row.lastAttemptAt, + lastOkAt: row.lastOkAt, + error: row.error || null, + report, + } +} + +/** Which servers know each permission name — the form's option source, and its warning label. */ +function catalogueByPermission(rows) { + const byPermission = new Map() + + for (const row of rows) { + if (!byPermission.has(row.permission)) byPermission.set(row.permission, []) + byPermission.get(row.permission).push(row.serverId) + } + + return [...byPermission.entries()] + .map(([permission, servers]) => ({ permission, servers })) + .sort((a, b) => a.permission.localeCompare(b.permission)) +} + +/** + * The whole authored set, read once, in the shape the per-server build wants. + * + * Read once per sync tick rather than once per server: six servers is six + * different answers derived from one set of tables, and re-reading them per + * server is six times the queries for the same rows. + */ +async function readAuthored() { + const [groups, groupPermissions, members, grants, links] = await Promise.all([ + db.listGroups(), + db.listGroupPermissions(), + db.listGroupMembers(), + db.listGrants(), + db.listLinks(), + ]) + + const steamIdsByUser = new Map() + + for (const link of links) { + if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, []) + steamIdsByUser.get(link.userId).push(link.steamId) + } + + return { groups, groupPermissions, members, grants, steamIdsByUser } +} + +/** + * What one server's store should contain, and the rows that say so. + * + * Returns three things the caller needs together and must not compute twice: + * + * `payload` what goes on the wire + * `rows` the same set in `rust_perm_pushed`'s shape, for the diff + * `hash` a stable digest of `rows`, which is how the loop knows nothing + * has changed without asking a game server + * + * **A user with no linked Steam account contributes nothing and is not an + * error.** They are authored against perfectly well and reach nobody until they + * link — which the admin screen says out loud, because a grant that reaches + * nothing looks exactly like one that worked. + */ +function buildDesired(serverId, authored) { + const { groups, groupPermissions, members, grants, steamIdsByUser } = authored + + const scopedGroups = groups.filter((group) => inScope(group.scope, serverId)) + const groupNames = new Set(scopedGroups.map((group) => group.name)) + + const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []])) + const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []])) + const managed = new Set() + const rows = [] + + for (const group of scopedGroups) + rows.push({ kind: 'group', subject: group.name, object: '' }) + + for (const row of groupPermissions) { + if (!groupNames.has(row.groupName)) continue + + const permission = normaliseName(row.permission) + permissionsByGroup.get(row.groupName).push(permission) + managed.add(permission) + rows.push({ kind: 'group-permission', subject: row.groupName, object: permission }) + } + + const seenMember = new Set() + + for (const row of members) { + if (!groupNames.has(row.groupName)) continue + + for (const steamId of steamIdsByUser.get(row.userId) || []) { + const key = `${row.groupName}:${steamId}` + if (seenMember.has(key)) continue + seenMember.add(key) + + membersByGroup.get(row.groupName).push(steamId) + rows.push({ kind: 'member', subject: steamId, object: row.groupName }) + } + } + + const permissionsBySteamId = new Map() + const seenGrant = new Set() + + for (const row of grants) { + if (!inScope(row.scope, serverId)) continue + + const permission = normaliseName(row.permission) + + // Managed whether or not it reaches anybody: the namespace is what makes a + // hand grant of this permission to somebody else show up as drift, and a + // grant whose holder has linked nothing would otherwise silently narrow it. + managed.add(permission) + + // **Resolved from the link map, not from the row.** `listGrants` joins the + // links and therefore repeats a grant once per linked account, which would + // give the right answer here by accident — until somebody changes that query + // and one of a person's two accounts quietly stops being granted. The map is + // the same source the members above use, and it says what it means. + for (const steamId of steamIdsByUser.get(row.userId) || []) { + const key = `${steamId}:${permission}` + if (seenGrant.has(key)) continue + seenGrant.add(key) + + if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, []) + permissionsBySteamId.get(steamId).push(permission) + rows.push({ kind: 'grant', subject: steamId, object: permission }) + } + } + + const payload = { + groups: scopedGroups.map((group) => ({ + name: group.name, + title: group.title || group.name, + rank: group.rank, + permissions: permissionsByGroup.get(group.name), + members: membersByGroup.get(group.name), + })), + grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ + steamId, + permissions, + })), + managed: [...managed].sort(), + } + + return { payload, rows, hash: hashRows(rows) } +} + +/** + * A digest of the desired set. + * + * Sorted before hashing, because the rows come out of several queries in an + * order nothing guarantees — an unsorted digest would differ between two reads + * of an unchanged set and push to every game server on every tick. + */ +function hashRows(rows) { + const canonical = rows + .map((row) => `${row.kind}${row.subject}${row.object}`) + .sort() + .join('\n') + + return crypto.createHash('sha256').update(canonical).digest('hex') +} + +/** A row's identity, for set arithmetic against what was pushed. */ +const rowKey = (row) => `${row.kind}${row.subject}${row.object}` + +/** + * What this site put in a server and has since withdrawn. + * + * `pushed − desired`, and it is the one calculation that cannot be replaced by + * asking the game: 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 have + * opposite correct answers (D31). Only the pushed ledger tells them apart. + */ +function retirements(pushed, desiredRows) { + const desired = new Set(desiredRows.map(rowKey)) + + return pushed.filter((row) => !desired.has(rowKey(row))) +} + +module.exports = { + FLEET, + normaliseName, + inScope, + overview, + readAuthored, + buildDesired, + retirements, + hashRows, + rowKey, + collapseGrants, + shapeSync, +} diff --git a/server/permSync.js b/server/permSync.js new file mode 100644 index 0000000..00881e1 --- /dev/null +++ b/server/permSync.js @@ -0,0 +1,342 @@ +// ── Keeping a game's permission store equal to what the site authored ───── +// +// R2's whole mechanism, and it is chapter 4's board pointed the other way: the +// site is the single producer of a set, it re-sends the whole thing rather than +// a stream of edits, and the receiver reconciles. What is new is the direction — +// the module telling the game what the site knows, where every earlier phase +// asked the game what it knew. +// +// ── One verb (D32) ──────────────────────────────────────────────────────── +// +// A sync sends the whole desired set and the plugin diffs it against the live +// store. The website never holds a copy of the game's permissions, which is the +// point: a second source of truth is stale the moment it lands, and the store is +// the bigger of the two sets. +// +// The delta the site DOES compute is the one the game cannot: what this site put +// there and has since withdrawn (`retirements`). A name in the store that is not +// in the desired set is either that, or a hand edit — and only the pushed ledger +// can tell them apart (D31). +// +// ── When it runs ────────────────────────────────────────────────────────── +// +// Every tick 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 therefore happens when: +// +// • an operator changed something (the dirty flag, and the digest behind it) +// • the game restarted or wiped (a new boot id or wipe id: the store may have +// been emptied, and R2's promise is that a wipe is not a data-loss event) +// • a permission hook fired in the game that we did not cause (`ingest.js` +// marks the server dirty; the authoritative answer is this sync's report) +// • the audit interval elapsed — the backstop that finds drift on a quiet +// server nobody has touched +// • the last attempt failed, after a backoff +// +// ── What it never does ──────────────────────────────────────────────────── +// +// It does not remove a grant it did not make (D31), it does not invent a +// permission the server has not registered (D33), and it does not treat a +// silent sidecar as a reason to forget anything. A server that is unreachable +// keeps its retirements and its revocations until it comes back. + +const core = require('./core') + +const db = require('./model/permissions/permissions.db') +const model = require('./model/permissions/permissions.model') +const servers = require('./model/servers/servers.model') +const serversDb = require('./model/servers/servers.db') +const sidecar = require('./sidecarClient') + +const log = core.logger('permissions') + +/** How often the loop asks whether anything needs pushing. */ +const TICK_MS = 30 * 1000 + +/** + * How long a server may go without a full reconciliation, however quiet it is. + * + * The digest comparison is what keeps the loop cheap, and on its own it would + * also mean a server whose store somebody edited by hand is never asked about + * again. This is the interval at which the question gets asked anyway. + */ +const AUDIT_MS = 15 * 60 * 1000 + +/** How long to leave a failing server alone before trying again. */ +const FAIL_BACKOFF_MS = 2 * 60 * 1000 + +/** + * The most rows one sync may carry. + * + * Below the sidecar's line cap and below the plugin's operation ceiling, so the + * refusal happens here — where it can name the server and reach an operator — + * rather than as a `413` or a `too-large` from two processes away. + */ +const MAX_ROWS = 15000 + +let timer = null + +function start() { + if (timer) return + + timer = setInterval(() => { + tick().catch((err) => log.error('permission sync tick failed', { error: err.message })) + }, TICK_MS) + + if (timer.unref) timer.unref() +} + +function stop() { + if (!timer) return + + clearInterval(timer) + timer = null +} + +/** + * One pass over every enabled server. + * + * The authored set is read ONCE and handed to each server's build: six servers + * are six different answers derived from the same four tables, and re-reading + * them per server is six times the queries for identical rows. + */ +async function tick({ force = null } = {}) { + await db.ensureSyncRows() + + const [rows, state, sync, authored] = await Promise.all([ + servers.listForPolling(), + serversDb.listState(), + db.listSync(), + model.readAuthored(), + ]) + + const syncById = new Map(sync.map((row) => [row.serverId, row])) + const stateById = new Map(state.map((row) => [row.serverId, row])) + + // `allSettled`, for the same reason the board poll uses it: one unreachable + // host must not stop the other five being reconciled. + await Promise.allSettled( + rows + .filter((server) => force === null || force === server.id) + .map((server) => + syncOne(server, { + authored, + sync: syncById.get(server.id) || null, + state: stateById.get(server.id) || null, + force: force !== null, + }), + ), + ) +} + +/** + * Whether this server needs a push right now. + * + * Returns a reason rather than a boolean, because the reason is worth logging: + * "why did the website just write to my game server" is a question an operator + * asks, and `wipe` and `drift` are very different answers. + */ +function reasonToSync({ desiredHash, sync, state, force }) { + if (force) return 'requested' + if (!sync) return 'first' + if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) { + return null + } + if (sync.state !== 'ok') return 'retry' + if (desiredHash !== sync.syncedHash) return 'changed' + if (sync.dirty) return 'dirty' + + const bootId = state && state.bootId ? state.bootId : null + const wipeId = state && state.wipeId ? state.wipeId : null + + // A restart or a wipe is the case R2 exists for: the game may have forgotten + // everything, and the site has not. + if (bootId && bootId !== sync.bootId) return 'restart' + if (wipeId && wipeId !== sync.wipeId) return 'wipe' + + if (!sync.lastAttemptAt || age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit' + + return null +} + +function age(value) { + const at = value instanceof Date ? value.getTime() : new Date(value).getTime() + return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER +} + +async function syncOne(server, { authored, sync, state, force }) { + const desired = model.buildDesired(server.id, authored) + const reason = reasonToSync({ desiredHash: desired.hash, sync, state, force }) + + if (!reason) return null + + const [pushed, revocations] = await Promise.all([ + db.listPushed(server.id), + db.listRevocations(server.id), + ]) + + const retirements = model.retirements(pushed, desired.rows) + const retire = [ + ...retirements.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })), + ...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })), + ] + + const bootId = state && state.bootId ? state.bootId : null + const wipeId = state && state.wipeId ? state.wipeId : null + + if (desired.rows.length + retire.length > MAX_ROWS) { + // Refused here rather than sent: the sidecar would answer `413` and the + // plugin would answer `too-large`, and neither of those messages reaches the + // person who has to make the set smaller. + const error = `the permission set is too large to push (${desired.rows.length + retire.length} rows, limit ${MAX_ROWS})` + log.error('permission sync refused', { server: server.id, rows: desired.rows.length }) + await db.putSyncResult(server.id, { + state: 'failed', + desiredHash: desired.hash, + syncedHash: sync ? sync.syncedHash : null, + bootId, + wipeId, + report: null, + error, + }) + + return 'too-large' + } + + log.info('syncing permissions', { + server: server.id, + reason, + rows: desired.rows.length, + retire: retire.length, + }) + + const result = await sidecar.permSync(server, { + setId: desired.hash, + groups: desired.payload.groups, + grants: desired.payload.grants, + managed: desired.payload.managed, + retire, + }) + + if (!result.ok) { + await db.putSyncResult(server.id, { + state: 'failed', + desiredHash: desired.hash, + syncedHash: sync ? sync.syncedHash : null, + bootId, + wipeId, + report: null, + error: result.status, + }) + + return result.status + } + + const report = result.data || {} + + // The plugin refuses a whole sync with `perm.error` — `busy` while an earlier + // one is still draining, `too-large` past its own ceiling. Both are answers + // rather than transport failures, exactly like a refused link code, so they + // arrive as a 200 and are told apart by `kind`. + if (report.kind === 'perm.error') { + await db.putSyncResult(server.id, { + state: 'failed', + desiredHash: desired.hash, + syncedHash: sync ? sync.syncedHash : null, + bootId, + wipeId, + report: null, + error: `the game refused the sync: ${report.reason || 'unknown'}`, + }) + + return report.reason || 'refused' + } + + await applyReport(server, { desired, retire, report, bootId, wipeId }) + + return 'ok' +} + +/** + * Record what the game said it did. + * + * Three writes, and the order matters only in that all three are safe to repeat: + * a sync that crashes here is re-run next tick and reaches the same place, which + * is the property that lets this loop be the only writer. + */ +async function applyReport(server, { desired, retire, report, bootId, wipeId }) { + const unresolved = new Set((report.unresolved || []).map(model.normaliseName)) + const pending = new Set(report.pending || []) + + // A grant naming a permission this server has not registered did NOT land — + // `GrantUserPermission` no-ops silently for an unregistered name, which is + // why the plugin pre-checks and says so. Recording it as pushed would make the + // site believe it had given a privilege it had not. + // + // The same for a member the store could not place: the membership is waiting + // on their first connection, and it is not in the game yet. + const landed = desired.rows.filter((row) => { + if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object) + if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`) + return true + }) + + await db.addPushed(server.id, landed) + + // Everything retired is gone from the game whether the plugin removed it or + // found it already absent, so it stops being something this site put there. + await db.removePushed(server.id, retire) + + const revocations = await db.listRevocations(server.id) + await db.deleteRevocations(revocations.map((row) => row.id)) + + await db.replaceDrift(server.id, (report.foreign || []).map((row) => ({ + kind: String(row.kind || ''), + subject: String(row.subject || ''), + object: String(row.object || ''), + }))) + + await db.putSyncResult(server.id, { + state: 'ok', + desiredHash: desired.hash, + syncedHash: desired.hash, + bootId, + wipeId, + report: JSON.stringify(report), + error: null, + }) + + // The option source, refreshed from the same server that just answered. It is + // a second round trip and it is worth it: the form must not offer a name that + // stopped being registered when somebody uninstalled a plugin, because a grant + // against one is a privilege nobody ever gets and nothing ever reports. + const catalogue = await sidecar.permCatalogue(server) + + if (catalogue.ok && catalogue.data && Array.isArray(catalogue.data.permissions)) { + await db.putCatalogue( + server.id, + catalogue.data.permissions.map(model.normaliseName).filter(Boolean), + ) + } + + log.info('permissions synced', { + server: server.id, + applied: report.applied, + unresolved: (report.unresolved || []).length, + foreign: (report.foreign || []).length, + pending: (report.pending || []).length, + }) +} + +module.exports = { + TICK_MS, + AUDIT_MS, + FAIL_BACKOFF_MS, + MAX_ROWS, + start, + stop, + tick, + syncOne, + reasonToSync, + applyReport, +} diff --git a/server/router/admin/permissions.controller.js b/server/router/admin/permissions.controller.js new file mode 100644 index 0000000..e2649c7 --- /dev/null +++ b/server/router/admin/permissions.controller.js @@ -0,0 +1,424 @@ +// ── Admin · Rust · Permissions ──────────────────────────────────────────── +// +// The authoring surface for R2. Everything here writes to the site's own tables +// and marks the affected servers dirty; nothing here talks to a game. The push +// is `permSync.js`'s loop, which is deliberate — a form that wrote to six game +// hosts inside the request would fail differently for each of them and have no +// honest status code to answer with. +// +// **The one exception is "sync now"**, which runs the loop's pass for one server +// and waits for it. It exists because an operator who has just changed something +// wants to see it land, and because waiting thirty seconds to find out that a +// server is unreachable is a bad way to learn it. +// +// Every write logs an activity row. These rows decide who may do what inside +// somebody's game server, which is the one thing on this module's admin tier +// more consequential than the sidecar credential. + +const core = require('../../core') + +const db = require('../../model/permissions/permissions.db') +const model = require('../../model/permissions/permissions.model') +const permSync = require('../../permSync') +const servers = require('../../model/servers/servers.model') + +const log = core.logger('admin:permissions') + +/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */ +async function overview(req, res) { + try { + res.json(await model.overview()) + } catch (err) { + log.error('failed to read the permission model', { error: err.message }) + res.status(500).json({ message: 'Failed to read the permission model' }) + } +} + +/** + * Create or update a group. + * + * The permission list is part of the same write, because that is how the form + * edits it: a group and what it carries are one idea on the screen, and two + * requests would leave a group briefly carrying the wrong set. + */ +async function putGroup(req, res) { + const name = model.normaliseName(req.params.name) + const scope = String(req.body.scope || model.FLEET) + + try { + if (scope !== model.FLEET && !(await knownServer(scope))) { + return res.status(400).json({ message: 'That scope names no configured server' }) + } + + const previous = await db.getGroup(name) + + await db.upsertGroup({ + name, + title: String(req.body.title || name), + rank: Number(req.body.rank) || 0, + scope, + }) + + const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean) + await db.setGroupPermissions(name, permissions) + + // Both scopes: a group that moved from one server to another has to be + // retired from where it was as well as applied where it now is, and only the + // old scope knows the first half. + await db.markDirty(scope) + if (previous && previous.scope !== scope) await db.markDirty(previous.scope) + + await core.activity.log({ + req, + action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create', + detail: { group: name, scope, permissions: permissions.length }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to save a group', { group: name, error: err.message }) + return res.status(500).json({ message: 'Failed to save that group' }) + } +} + +async function deleteGroup(req, res) { + const name = model.normaliseName(req.params.name) + + try { + const existing = await db.getGroup(name) + if (!existing) return res.status(404).json({ message: 'No such group' }) + + await db.deleteGroup(name) + await db.markDirty(existing.scope) + + await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { group: name } }) + + return res.status(204).end() + } catch (err) { + log.error('failed to delete a group', { group: name, error: err.message }) + return res.status(500).json({ message: 'Failed to delete that group' }) + } +} + +async function addMember(req, res) { + const name = model.normaliseName(req.params.name) + + try { + const group = await db.getGroup(name) + if (!group) return res.status(404).json({ message: 'No such group' }) + + const userId = await resolveUser(req.body) + if (!userId) return res.status(404).json({ message: 'No account on this site has that name' }) + + await db.addGroupMember(name, userId, req.user ? req.user.id : null) + await db.markDirty(group.scope) + + await core.activity.log({ + req, + action: 'rust.perm.member.add', + detail: { group: name, userId }, + }) + + return res.status(204).end() + } catch (err) { + // A user id that names nobody fails on the foreign key rather than on a + // check of our own: the row is the constraint, and one round trip is + // cheaper than two. + log.error('failed to add a member', { group: name, userId, error: err.message }) + return res.status(400).json({ message: 'That account could not be added to the group' }) + } +} + +async function removeMember(req, res) { + const name = model.normaliseName(req.params.name) + const userId = Number(req.params.userId) + + try { + const group = await db.getGroup(name) + if (!group) return res.status(404).json({ message: 'No such group' }) + + const removed = await db.removeGroupMember(name, userId) + if (!removed) return res.status(404).json({ message: 'That account is not in the group' }) + + await db.markDirty(group.scope) + await core.activity.log({ + req, + action: 'rust.perm.member.remove', + detail: { group: name, userId }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to remove a member', { group: name, userId, error: err.message }) + return res.status(500).json({ message: 'Failed to remove that account from the group' }) + } +} + +/** + * Grant one permission to one person. + * + * `source` is fixed at `admin` here and is not accepted from the body: the + * column exists so phase 13's event actions can write their own rows through the + * same table, and a route that let a caller choose would make "who gave this" + * unanswerable the first time somebody passed the wrong string. + */ +async function addGrant(req, res) { + const permission = model.normaliseName(req.body.permission) + const scope = String(req.body.scope || model.FLEET) + let userId = null + + try { + if (scope !== model.FLEET && !(await knownServer(scope))) { + return res.status(400).json({ message: 'That scope names no configured server' }) + } + + userId = await resolveUser(req.body) + if (!userId) return res.status(404).json({ message: 'No account on this site has that name' }) + + const { inserted } = await db.insertGrant({ + userId, + permission, + scope, + source: 'admin', + note: req.body.note ? String(req.body.note).slice(0, 255) : null, + grantedBy: req.user ? req.user.id : null, + }) + + if (inserted) { + await db.markDirty(scope) + await core.activity.log({ + req, + action: 'rust.perm.grant', + detail: { userId, permission, scope }, + }) + } + + return res.status(inserted ? 201 : 200).json({ granted: inserted }) + } catch (err) { + log.error('failed to grant', { userId, permission, error: err.message }) + return res.status(400).json({ message: 'That permission could not be granted' }) + } +} + +async function removeGrant(req, res) { + const id = Number(req.params.id) + + try { + const grant = await db.getGrant(id) + if (!grant) return res.status(404).json({ message: 'No such grant' }) + + await db.deleteGrant(id) + await db.markDirty(grant.scope) + + await core.activity.log({ + req, + action: 'rust.perm.revoke', + detail: { userId: grant.userId, permission: grant.permission, scope: grant.scope }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to revoke a grant', { grant: id, error: err.message }) + return res.status(500).json({ message: 'Failed to remove that grant' }) + } +} + +/** + * Adopt a hand edit: the site records it as its own. + * + * It is only possible for a `grant` whose Steam id belongs to a website account, + * and the refusal says so — because the alternative is authoring privilege + * against a game account no person on this site holds, which is precisely the + * thing D28 decided not to do. + */ +async function adoptDrift(req, res) { + const id = Number(req.params.id) + + try { + const row = await db.getDrift(id) + if (!row) return res.status(404).json({ message: 'No such drift' }) + + if (row.kind !== 'grant' && row.kind !== 'member') { + return res.status(400).json({ + message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.', + }) + } + + const holder = await holderOf(row.subject) + + if (!holder) { + return res.status(409).json({ + message: + 'That Steam account is not linked to any account on this site, so there is nobody to author this against. Revoke it instead, or ask the player to link.', + }) + } + + if (row.kind === 'grant') { + await db.insertGrant({ + userId: holder.userId, + permission: row.object, + scope: row.serverId, + source: 'adopted', + note: 'Adopted from a hand edit', + grantedBy: req.user ? req.user.id : null, + }) + } else { + const group = await db.getGroup(row.object) + if (!group) return res.status(409).json({ message: 'That group is not authored on this site' }) + + await db.addGroupMember(row.object, holder.userId, req.user ? req.user.id : null) + } + + // Already in the game, so it is already pushed — recorded as such rather + // than left for the next sync to "apply". Without this the row would be + // desired-but-not-pushed, which is a state the loop would happily write + // again and the game would report as already correct: harmless, and a lie in + // the one table that exists to say what this site put there. + await db.addPushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }]) + await db.deleteDrift(id) + await db.markDirty(row.serverId) + + await core.activity.log({ + req, + action: 'rust.perm.drift.adopt', + detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to adopt drift', { drift: id, error: err.message }) + return res.status(500).json({ message: 'Failed to adopt that change' }) + } +} + +/** + * Revoke a hand edit. + * + * Queued rather than sent: the server may be down, and an instruction that is + * dropped because a game host was restarting is exactly the behaviour a site + * claiming to be the author of record must not have. The next successful sync + * carries it and the queue row goes. + */ +async function revokeDrift(req, res) { + const id = Number(req.params.id) + + try { + const row = await db.getDrift(id) + if (!row) return res.status(404).json({ message: 'No such drift' }) + + await db.queueRevocation({ + serverId: row.serverId, + kind: row.kind, + subject: row.subject, + object: row.object, + requestedBy: req.user ? req.user.id : null, + }) + + await db.deleteDrift(id) + await db.markDirty(row.serverId) + + await core.activity.log({ + req, + action: 'rust.perm.drift.revoke', + detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object }, + }) + + return res.status(202).json({ queued: true }) + } catch (err) { + log.error('failed to queue a revocation', { drift: id, error: err.message }) + return res.status(500).json({ message: 'Failed to queue that revocation' }) + } +} + +/** Run the loop's pass now, for one server or for all of them, and report what happened. */ +async function syncNow(req, res) { + const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null + + try { + if (serverId && !(await knownServer(serverId))) { + return res.status(404).json({ message: 'No such server' }) + } + + await db.markDirty(serverId || model.FLEET) + await permSync.tick({ force: serverId }) + + await core.activity.log({ + req, + action: 'rust.perm.sync', + detail: { server: serverId || 'all' }, + }) + + const state = await model.overview() + return res.json({ servers: state.servers, drift: state.drift }) + } catch (err) { + log.error('a forced sync failed', { server: serverId, error: err.message }) + return res.status(500).json({ message: 'Failed to run the sync' }) + } +} + +/** Every permission name any configured server has registered, with which ones know it. */ +async function catalogue(req, res) { + try { + const rows = await db.listCatalogue() + res.json({ permissions: groupCatalogue(rows) }) + } catch (err) { + log.error('failed to read the catalogue', { error: err.message }) + res.status(500).json({ message: 'Failed to read the permission catalogue' }) + } +} + +function groupCatalogue(rows) { + const byPermission = new Map() + + for (const row of rows) { + if (!byPermission.has(row.permission)) byPermission.set(row.permission, []) + byPermission.get(row.permission).push(row.serverId) + } + + return [...byPermission.entries()] + .map(([permission, serverIds]) => ({ permission, servers: serverIds })) + .sort((a, b) => a.permission.localeCompare(b.permission)) +} + +/** + * The user id a write is about, from either an id or a username. + * + * The form sends a name, because a form that made an operator type a numeric id + * would be a form nobody could use. The id form stays accepted because the + * client already holds one on the panel inside core's user page, and looking a + * name back up from it would be a round trip to answer a question it has + * already answered. + */ +async function resolveUser(body) { + if (body.userId) return Number(body.userId) + if (!body.username) return null + + const user = await db.findUserByUsername(String(body.username).trim()) + return user ? user.id : null +} + +/** Whether a scope names a server row. A disabled server still counts — it exists. */ +async function knownServer(id) { + const rows = await servers.listForAdmin() + return rows.some((row) => row.id === id) +} + +/** The website account that holds a Steam id, or null. */ +async function holderOf(steamId) { + const links = await db.listLinks() + return links.find((link) => link.steamId === steamId) || null +} + +module.exports = { + overview, + putGroup, + deleteGroup, + addMember, + removeMember, + addGrant, + removeGrant, + adoptDrift, + revokeDrift, + syncNow, + catalogue, +} diff --git a/server/router/admin/permissions.router.js b/server/router/admin/permissions.router.js new file mode 100644 index 0000000..69a3f3f --- /dev/null +++ b/server/router/admin/permissions.router.js @@ -0,0 +1,184 @@ +// ── Admin · Rust · Permissions ──────────────────────────────────────────── +// +// Mounted under the admin tier's `/rust` prefix, so every path here is +// `/api/v1/admin/rust/permissions…`. It is a second router rather than more +// routes on `rust.router.js` because it is a second subject: that one configures +// the bridge, this one authors privilege inside somebody's game. +// +// **Every route is `requireRole('admin')`.** The admin tier's own gate admits +// editors and moderators, and a moderator being able to grant themselves +// `kits.admin` on six servers is the whole of R1's "a weak link is now a +// privilege-escalation path" arriving through the front door instead. The tier +// gate is not re-implemented; this is one gate on top of it, exactly as the +// server-configuration routes do it. +// +// There is no module-declared site permission to gate these more finely with — +// `MODULE_API.md` has no such member at 1.10.0 — so role is the whole of the +// available vocabulary, and `admin` is the honest choice within it. + +const core = require('../../core') + +const express = core.express +const permissions = require('./permissions.controller') +const { requireRole, validate } = core.middleware +const { body, param } = core.validator + +const permissionsRouter = express.Router() + +/** A permission or group name, as both mod frameworks store them. */ +const NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/i + +permissionsRouter.get( + '/', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'The whole permission model' + // #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.' + /* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */ + requireRole('admin'), + permissions.overview, +) + +permissionsRouter.get( + '/catalogue', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Permission names the servers have registered' + // #swagger.description = 'What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.' + /* #swagger.responses[200] = { description: 'Every registered name, and which servers know it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */ + requireRole('admin'), + permissions.catalogue, +) + +permissionsRouter.put( + '/groups/:name', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Create or update a permission group' + // #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.' + /* #swagger.responses[204] = { description: 'Saved' } */ + /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */ + requireRole('admin'), + param('name').matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'), + body('title').optional().isString().trim().isLength({ max: 120 }), + body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(), + body('scope').optional().isString().isLength({ min: 1, max: 64 }), + body('permissions').optional().isArray({ max: 500 }), + body('permissions.*').isString().matches(NAME), + validate, + permissions.putGroup, +) + +permissionsRouter.delete( + '/groups/:name', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Delete a permission group' + // #swagger.description = 'Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.' + /* #swagger.responses[204] = { description: 'Deleted' } */ + /* #swagger.responses[404] = { description: 'No such group' } */ + requireRole('admin'), + param('name').isString().isLength({ min: 1, max: 64 }), + validate, + permissions.deleteGroup, +) + +permissionsRouter.post( + '/groups/:name/members', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Put an account in a group' + // #swagger.description = 'Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.' + /* #swagger.responses[204] = { description: 'Added' } */ + /* #swagger.responses[404] = { description: 'No such group' } */ + requireRole('admin'), + param('name').isString().isLength({ min: 1, max: 64 }), + // Either identifier: the screen sends a name, the panel inside core's own user + // page already holds an id. + body('userId').optional().isInt({ min: 1 }).toInt(), + body('username').optional().isString().trim().isLength({ min: 1, max: 64 }), + validate, + permissions.addMember, +) + +permissionsRouter.delete( + '/groups/:name/members/:userId', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Take an account out of a group' + /* #swagger.responses[204] = { description: 'Removed' } */ + /* #swagger.responses[404] = { description: 'No such group, or that account is not in it' } */ + requireRole('admin'), + param('name').isString().isLength({ min: 1, max: 64 }), + param('userId').isInt({ min: 1 }).toInt(), + validate, + permissions.removeMember, +) + +permissionsRouter.post( + '/grants', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Grant one permission to one person' + // #swagger.description = 'A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.' + /* #swagger.responses[201] = { description: 'Granted' } */ + /* #swagger.responses[200] = { description: 'They already held it; nothing changed' } */ + /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */ + /* #swagger.responses[404] = { description: 'No account on this site has that name' } */ + requireRole('admin'), + body('userId').optional().isInt({ min: 1 }).toInt(), + body('username').optional().isString().trim().isLength({ min: 1, max: 64 }), + body('permission').isString().matches(NAME), + body('scope').optional().isString().isLength({ min: 1, max: 64 }), + body('note').optional().isString().isLength({ max: 255 }), + validate, + permissions.addGrant, +) + +permissionsRouter.delete( + '/grants/:id', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Remove a grant' + // #swagger.description = 'The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.' + /* #swagger.responses[204] = { description: 'Removed' } */ + /* #swagger.responses[404] = { description: 'No such grant' } */ + requireRole('admin'), + param('id').isInt({ min: 1 }).toInt(), + validate, + permissions.removeGrant, +) + +permissionsRouter.post( + '/drift/:id/adopt', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Adopt a hand edit' + // #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.' + /* #swagger.responses[204] = { description: 'Adopted' } */ + /* #swagger.responses[400] = { description: 'That kind of drift cannot be adopted' } */ + /* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */ + requireRole('admin'), + param('id').isInt({ min: 1 }).toInt(), + validate, + permissions.adoptDrift, +) + +permissionsRouter.post( + '/drift/:id/revoke', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Revoke a hand edit' + // #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.' + /* #swagger.responses[202] = { description: 'Queued for the next sync' } */ + /* #swagger.responses[404] = { description: 'No such drift' } */ + requireRole('admin'), + param('id').isInt({ min: 1 }).toInt(), + validate, + permissions.revokeDrift, +) + +permissionsRouter.post( + '/sync', + // #swagger.tags = ['Admin · Rust'] + // #swagger.summary = 'Push the permission set now' + // #swagger.description = 'Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.' + /* #swagger.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */ + /* #swagger.responses[404] = { description: 'No such server' } */ + requireRole('admin'), + body('serverId').optional().isString().isLength({ min: 1, max: 64 }), + validate, + permissions.syncNow, +) + +module.exports = permissionsRouter diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js index c91d993..c0f7019 100644 --- a/server/router/admin/rust.router.js +++ b/server/router/admin/rust.router.js @@ -27,6 +27,11 @@ const { body, param } = core.validator const adminRustRouter = express.Router() +// R2's authoring surface, under `/rust/permissions`. Its own file because it is +// its own subject — this router configures the bridge, that one decides who may +// do what inside the game the bridge reaches. +adminRustRouter.use('/permissions', require('./permissions.router')) + adminRustRouter.get( '/servers', // #swagger.tags = ['Admin · Rust'] diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js index 6fb2592..b45cd54 100644 --- a/server/router/admin/usersRust.controller.js +++ b/server/router/admin/usersRust.controller.js @@ -8,6 +8,9 @@ const core = require('../../core') const links = require('../../model/links/links.model') +const permissionsDb = require('../../model/permissions/permissions.db') +const permissions = require('../../model/permissions/permissions.model') +const servers = require('../../model/servers/servers.model') const log = core.logger('admin') @@ -68,4 +71,132 @@ async function removeLink(req, res) { } } -module.exports = { listLinks, removeLink } +/** + * GET /admin/users/:id/rust/permissions + * + * What this person may do in game, and — the part that is easy to leave out — + * whether any of it reaches anybody. A grant against an account with no linked + * Steam id is authored, stored, pushed nowhere and looks identical to a working + * one on every screen that does not say so. + */ +async function listPermissions(req, res) { + const userId = Number(req.params.id) + + try { + const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([ + permissionsDb.listGroups(), + permissionsDb.listGroupPermissions(), + permissionsDb.listGroupMembers(), + permissionsDb.listGrants({ userId }), + permissionsDb.listLinks(), + ]) + + const theirs = new Set( + members.filter((row) => row.userId === userId).map((row) => row.groupName), + ) + + const carried = new Map() + for (const row of groupPermissions) { + if (!carried.has(row.groupName)) carried.set(row.groupName, []) + carried.get(row.groupName).push(row.permission) + } + + res.json({ + groups: groups + .filter((group) => theirs.has(group.name)) + .map((group) => ({ + name: group.name, + title: group.title, + scope: group.scope, + permissions: carried.get(group.name) || [], + })), + grants: permissions.collapseGrants(grants).map((grant) => ({ + id: grant.id, + permission: grant.permission, + scope: grant.scope, + source: grant.source, + note: grant.note, + grantedAt: grant.grantedAt, + })), + reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId), + }) + } catch (err) { + log.error('failed to read a user’s Rust permissions', { error: err.message }) + res.status(500).json({ message: 'Failed to read this user’s Rust permissions' }) + } +} + +/** POST /admin/users/:id/rust/permissions/grants */ +async function addGrant(req, res) { + const userId = Number(req.params.id) + const permission = permissions.normaliseName(req.body.permission) + const scope = String(req.body.scope || permissions.FLEET) + + try { + if (scope !== permissions.FLEET) { + const known = await servers.listForAdmin() + if (!known.some((row) => row.id === scope)) { + return res.status(400).json({ message: 'That scope names no configured server' }) + } + } + + const { inserted } = await permissionsDb.insertGrant({ + userId, + permission, + scope, + source: 'admin', + note: null, + grantedBy: req.user ? req.user.id : null, + }) + + if (inserted) { + await permissionsDb.markDirty(scope) + await core.activity.log({ + req, + action: 'rust.perm.grant', + detail: { userId, permission, scope }, + }) + } + + return res.status(inserted ? 201 : 200).json({ granted: inserted }) + } catch (err) { + log.error('failed to grant a permission', { userId, permission, error: err.message }) + return res.status(400).json({ message: 'That permission could not be granted' }) + } +} + +/** + * DELETE /admin/users/:id/rust/permissions/grants/:grantId + * + * **Scoped by the user as well as by the grant**, like every other write in this + * panel: a grant id belonging to somebody else answers `404` rather than + * removing a privilege from a person whose page nobody was looking at. + */ +async function removeGrant(req, res) { + const userId = Number(req.params.id) + const grantId = Number(req.params.grantId) + + try { + const grant = await permissionsDb.getGrant(grantId) + + if (!grant || grant.userId !== userId) { + return res.status(404).json({ message: 'That grant does not belong to this user' }) + } + + await permissionsDb.deleteGrant(grantId) + await permissionsDb.markDirty(grant.scope) + + await core.activity.log({ + req, + action: 'rust.perm.revoke', + detail: { userId, permission: grant.permission, scope: grant.scope }, + }) + + return res.status(204).end() + } catch (err) { + log.error('failed to remove a grant', { userId, grant: grantId, error: err.message }) + return res.status(500).json({ message: 'Failed to remove that permission' }) + } +} + +module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant } diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js index feed707..24c830c 100644 --- a/server/router/admin/usersRust.router.js +++ b/server/router/admin/usersRust.router.js @@ -30,7 +30,7 @@ const core = require('../../core') const express = core.express -const { param } = core.validator +const { body, param } = core.validator const usersRust = require('./usersRust.controller') const { validate } = core.middleware @@ -70,4 +70,62 @@ usersRustRouter.delete( usersRust.removeLink, ) +// ── Phase 7: what this person may do in game ───────────────────────────── +// +// The same panel, one section lower. It is here rather than only on the +// permissions screen because the question an operator actually has is about a +// PERSON — "why can this player spawn a kit" is asked on their page, not on a +// list of groups — and because the slot is already the place this module says +// everything else it knows about one user. +// +// Both writes go through the ordinary authored tables and the ordinary loop. A +// grant made here reaches the game when the mirror next reconciles, which is +// seconds, and never inside this request. + +usersRustRouter.get( + '/rust/permissions', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s Rust privileges (admin only)' + // #swagger.description = 'The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Their groups and grants', content: { "application/json": { schema: { $ref: "#/components/schemas/RustUserPermissions" } } } } */ + param('id').isInt(), + validate, + usersRust.listPermissions, +) + +usersRustRouter.post( + '/rust/permissions/grants', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Grant a Rust permission to this user (admin only)' + // #swagger.description = 'Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[201] = { description: 'Granted' } */ + /* #swagger.responses[200] = { description: 'They already held it' } */ + /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('permission').isString().matches(/^[a-z0-9][a-z0-9._-]{0,127}$/i), + body('scope').optional().isString().isLength({ min: 1, max: 64 }), + validate, + usersRust.addGrant, +) + +usersRustRouter.delete( + '/rust/permissions/grants/:grantId', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Remove a Rust permission from this user (admin only)' + // #swagger.description = 'Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['grantId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grant to remove.' } + /* #swagger.responses[204] = { description: 'Removed' } */ + /* #swagger.responses[404] = { description: 'No such grant for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + param('grantId').isInt({ min: 1 }).toInt(), + validate, + usersRust.removeGrant, +) + module.exports = usersRustRouter diff --git a/server/scripts/swaggerFragment.js b/server/scripts/swaggerFragment.js index 09a6c97..511e05e 100644 --- a/server/scripts/swaggerFragment.js +++ b/server/scripts/swaggerFragment.js @@ -76,6 +76,7 @@ const SLOT_MOUNT = { 'admin.users.detail': '/api/v1/admin/users/:id', } + /** * Run `register()` with a recording api and return `[{ file, prefix, what }]`. * diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 8683704..a13b1fe 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -52,9 +52,11 @@ const TIMEOUT_MS = 12000 * here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge * plugin, and `protocol` in its `overlay.toml`. * - * **3 — identity.** Protocol 2 was the read path; 3 adds the first message the - * WEBSITE originates (`link.confirm`) and the two account frames the plugin - * emits beside it. The bump lands here in the same change as the emitters, + * **4 — the permission mirror.** Protocol 2 was the read path, 3 the first + * message the WEBSITE originates (`link.confirm`); 4 is the first that WRITES + * to the game — the whole permission set the site authors for one server, and + * the report the plugin sends back. The bump lands here in the same change as + * the emitters, * because the sidecar refuses a client declaring a different version with a * `409`: a module left on 2 would stop being able to read the server board it * has been reading all along. A constant that lags the deployment is not a safe @@ -64,7 +66,7 @@ const TIMEOUT_MS = 12000 * deployment into a `409` naming both numbers instead of a parse failure three * layers further in. */ -const PROTOCOL_VERSION = 3 +const PROTOCOL_VERSION = 4 /** What a caller gets back. Shaped once so every call site reads the same. */ function reply(ok, status, data = null) { @@ -210,6 +212,33 @@ const feedTail = (server) => request(server, '/feed') const confirmLink = (server, code) => request(server, '/link/confirm', { method: 'POST', body: { code } }) +/** + * What one server's loaded plugins have registered, and the groups its store + * holds (protocol 4). + * + * The option source behind the authoring form (D33). It is a live read through + * to the game rather than anything cached at the sidecar, because the answer + * changes when an operator loads a plugin — and the whole reason to ask is to + * offer names that will actually resolve. It therefore fails when the game is + * down, like `/status` and unlike every store-backed read. + */ +const permCatalogue = (server) => request(server, '/permissions/catalogue') + +/** + * Push the whole permission set this site authors for one server (protocol 4). + * + * **The second call in this file that is not a GET, and the first that changes + * the game.** The body is the desired set plus what the site has withdrawn; the + * plugin diffs it against the live store, applies the difference and answers + * with a report — counts, the names it could not resolve, the memberships that + * are waiting on a first connection, and every holder the site did not author. + * + * **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error` + * and `perm.report` are both answers, and the sidecar keeps its own status codes + * for the transport. The caller discriminates on `data.kind`. + */ +const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set }) + module.exports = { TIMEOUT_MS, PROTOCOL_VERSION, @@ -221,5 +250,7 @@ module.exports = { feed, feedTail, confirmLink, + permCatalogue, + permSync, joinUrl, } diff --git a/server/swagger/doc.js b/server/swagger/doc.js index ca16863..27fa245 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -195,6 +195,237 @@ module.exports = { }, }, }, + RustPermissionModel: { + type: 'object', + description: + 'The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use.', + properties: { + groups: { + type: 'array', + description: 'Groups the site authors, mirrored into each in-scope game as a real group.', + items: { + type: 'object', + properties: { + name: { type: 'string', example: 'vip' }, + title: { type: 'string', example: 'VIP' }, + rank: { type: 'integer', example: 10 }, + scope: { + type: 'string', + description: 'A server id, or `*` for every server.', + example: '*', + }, + permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } }, + members: { + type: 'array', + items: { + type: 'object', + properties: { + userId: { type: 'integer', example: 42 }, + username: { type: 'string', example: 'wanderer' }, + steamId: { + type: 'string', + nullable: true, + description: 'Null when this account has linked no Steam id, in which case the membership reaches nobody yet.', + example: '76561198000000000', + }, + playerName: { type: 'string', nullable: true, example: 'Wanderer' }, + }, + }, + }, + }, + }, + }, + grants: { + type: 'array', + description: 'Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected.', + items: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + userId: { type: 'integer', example: 42 }, + username: { type: 'string', example: 'wanderer' }, + permission: { type: 'string', example: 'kits.gold' }, + scope: { type: 'string', example: 'main' }, + source: { + type: 'string', + description: 'What authored it — `admin`, `adopted`, or a later phase’s own writer.', + example: 'admin', + }, + note: { type: 'string', nullable: true, example: null }, + grantedAt: { type: 'string', format: 'date-time' }, + accounts: { + type: 'array', + description: 'The Steam accounts this grant reaches. Empty means it reaches nobody yet.', + items: { + type: 'object', + properties: { + steamId: { type: 'string', example: '76561198000000000' }, + name: { type: 'string', nullable: true, example: 'Wanderer' }, + }, + }, + }, + }, + }, + }, + servers: { + type: 'array', + description: 'The state of the mirror, per configured server.', + items: { $ref: '#/components/schemas/RustPermissionSyncState' }, + }, + drift: { + type: 'array', + description: 'What a game holds that the site did not author. Reported, never undone.', + items: { + type: 'object', + properties: { + id: { type: 'integer', example: 3 }, + serverId: { type: 'string', example: 'main' }, + kind: { + type: 'string', + description: 'One of `grant`, `member`, `group-permission`.', + example: 'grant', + }, + subject: { + type: 'string', + description: 'A Steam id, or a group name.', + example: '76561198000000000', + }, + object: { + type: 'string', + description: 'A permission name, or a group name.', + example: 'kits.admin', + }, + username: { + type: 'string', + nullable: true, + description: 'The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked.', + example: 'wanderer', + }, + firstSeen: { type: 'string', format: 'date-time' }, + }, + }, + }, + catalogue: { + type: 'array', + items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' }, + }, + }, + }, + RustPermissionSyncState: { + type: 'object', + description: 'Whether one server’s store matches what the site authors, and what its last report said.', + properties: { + serverId: { type: 'string', example: 'main' }, + state: { + type: 'string', + description: 'One of `pending`, `ok`, `failed`.', + example: 'ok', + }, + inSync: { + type: 'boolean', + description: 'True when the last successful push carried the set the site currently authors.', + example: true, + }, + dirty: { type: 'boolean', example: false }, + lastAttemptAt: { type: 'string', format: 'date-time', nullable: true }, + lastOkAt: { type: 'string', format: 'date-time', nullable: true }, + error: { + type: 'string', + nullable: true, + description: 'Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the game’s own refusal.', + example: null, + }, + report: { + type: 'object', + nullable: true, + description: 'The plugin’s report from the last successful sync.', + properties: { + applied: { + type: 'object', + properties: { + grants: { type: 'integer', example: 2 }, + revokes: { type: 'integer', example: 0 }, + groupsCreated: { type: 'integer', example: 1 }, + members: { type: 'integer', example: 3 }, + }, + }, + alreadyCorrect: { type: 'integer', example: 14 }, + unresolved: { + type: 'array', + description: 'Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed.', + items: { type: 'string', example: 'kits.gold' }, + }, + pending: { + type: 'array', + description: 'Memberships waiting on a first connection: the store has no user record to put in a group yet.', + items: { type: 'string', example: '76561198000000000:vip' }, + }, + }, + }, + }, + }, + RustPermissionCatalogue: { + type: 'object', + description: 'Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue).', + properties: { + permissions: { + type: 'array', + items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' }, + }, + }, + }, + RustPermissionCatalogueEntry: { + type: 'object', + description: 'One registered permission name, and which servers know it.', + properties: { + permission: { type: 'string', example: 'kits.vip' }, + servers: { type: 'array', items: { type: 'string', example: 'main' } }, + }, + }, + RustPermissionSyncResult: { + type: 'object', + description: 'What a forced sync produced (POST /admin/rust/permissions/sync).', + properties: { + servers: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionSyncState' } }, + drift: { type: 'array', items: { type: 'object' } }, + }, + }, + RustUserPermissions: { + type: 'object', + description: 'One person’s Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions).', + properties: { + groups: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', example: 'vip' }, + title: { type: 'string', example: 'VIP' }, + scope: { type: 'string', example: '*' }, + permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } }, + }, + }, + }, + grants: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer', example: 7 }, + permission: { type: 'string', example: 'kits.gold' }, + scope: { type: 'string', example: 'main' }, + source: { type: 'string', example: 'admin' }, + grantedAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + reaches: { + type: 'array', + description: 'The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only.', + items: { type: 'string', example: '76561198000000000' }, + }, + }, + }, RustSidecarProbe: { type: 'object', description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).', diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index b718ec7..d97836d 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -87,13 +87,13 @@ test('every kind is classified exactly once', () => { assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length) }) -test('the classification covers exactly the kinds protocol 3 defines', () => { +test('the classification covers exactly the kinds protocol 4 defines', () => { // The spec lives in another repository, so the list is restated here rather // than parsed — and restating it is the point: adding a kind to the protocol // without deciding who may see it has to fail somewhere, and this is where. // // Sourced from docs/rust-link/PROTOCOL.md §8.4. - const PROTOCOL_3 = [ + const PROTOCOL_4 = [ 'player.connected', 'player.disconnected', 'player.respawned', @@ -111,7 +111,8 @@ test('the classification covers exactly the kinds protocol 3 defines', () => { 'server.shutdown', 'account.link.requested', 'account.unlinked', + 'perm.drift', ] - assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort()) + assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort()) }) diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js index ee0a18b..12a2851 100644 --- a/server/test/identityRoutes.test.js +++ b/server/test/identityRoutes.test.js @@ -74,7 +74,14 @@ test('the admin.users.detail router merges the parent’s params and keeps its o assert.equal(slot.router.mergeParams, true) const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort() - assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links']) + assert.deepEqual(paths, [ + 'DELETE /rust/links/:steamId', + // Phase 7 filled the same panel with what this person may do in game. + 'DELETE /rust/permissions/grants/:grantId', + 'GET /rust/links', + 'GET /rust/permissions', + 'POST /rust/permissions/grants', + ]) for (const route of routesOf(slot.router)) { assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`) diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js new file mode 100644 index 0000000..bf24bb8 --- /dev/null +++ b/server/test/permissions.test.js @@ -0,0 +1,285 @@ +// ── The permission mirror ───────────────────────────────────────────────── +// +// The whole of R2's correctness is three set operations and one rule about what +// counts as landed, and every test here is one of those: +// +// desired − pushed apply +// pushed − desired RETIRE, because the site put it there and withdrew it +// present − desired drift, which is reported and never undone +// +// and: a grant naming a permission the server has not registered did NOT land, +// however much the push looked like it worked. +// +// The last one is the one with teeth. `GrantUserPermission` returns void, throws +// nothing and logs nothing for an unregistered name (PLAN.md §12.2 rule 1), so a +// module that recorded it as pushed would believe it had given a privilege it had +// not — and would then RETIRE it from a server that never had it, which is a +// no-op that reads as a success in every log. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +function withCore(overrides = {}) { + const queries = [] + + require('../core')._reset() + require('../core').init( + fakeCtx({ + db: { + query: (sql, params) => { + queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params }) + const verb = sql.trim().split(/\s+/)[0].toUpperCase() + if (verb === 'SELECT') return Promise.resolve([]) + return Promise.resolve({ affectedRows: 1 }) + }, + pool: {}, + }, + ...overrides, + }), + ) + + return queries +} + +/** One authored set: a fleet group, a server-scoped group, and two grants. */ +function authored() { + return { + groups: [ + { name: 'vip', title: 'VIP', rank: 10, scope: '*' }, + { name: 'builder', title: 'Builder', rank: 0, scope: 'creative' }, + ], + groupPermissions: [ + { groupName: 'vip', permission: 'kits.vip' }, + { groupName: 'builder', permission: 'buildtools.use' }, + ], + members: [ + { groupName: 'vip', userId: 1 }, + { groupName: 'builder', userId: 2 }, + ], + grants: [ + { id: 1, userId: 1, permission: 'kits.gold', scope: '*', steamId: '7656001' }, + { id: 2, userId: 3, permission: 'kits.gold', scope: '*', steamId: null }, + { id: 3, userId: 2, permission: 'zonemanager.admin', scope: 'creative', steamId: '7656002' }, + ], + // One person with TWO Steam accounts, one with one, one with none. + steamIdsByUser: new Map([ + [1, ['7656001', '7656099']], + [2, ['7656002']], + ]), + } +} + +test('a grant reaches every Steam account its holder has linked (D28)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const { payload } = model.buildDesired('main', authored()) + const holders = payload.grants.map((row) => row.steamId).sort() + + // `kits.gold` is authored once, against user 1, who holds two accounts. + assert.deepEqual(holders, ['7656001', '7656099']) + for (const row of payload.grants) assert.deepEqual(row.permissions, ['kits.gold']) +}) + +test('a holder who has linked nothing contributes to the namespace but reaches nobody', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const { payload, rows } = model.buildDesired('main', authored()) + + // User 3 holds `kits.gold` and has no account. Nothing is pushed for them… + assert.ok(!rows.some((row) => row.kind === 'grant' && row.subject === null)) + // …and the permission is still MANAGED, which is what makes a hand grant of it + // to somebody else show up as drift rather than as nothing at all. + assert.ok(payload.managed.includes('kits.gold')) +}) + +test('scope decides what a server is sent at all (D29)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const main = model.buildDesired('main', authored()) + const creative = model.buildDesired('creative', authored()) + + assert.deepEqual(main.payload.groups.map((g) => g.name), ['vip']) + assert.deepEqual(creative.payload.groups.map((g) => g.name).sort(), ['builder', 'vip']) + + // The server-scoped grant is on `creative` and nowhere else. + assert.ok(!main.payload.managed.includes('zonemanager.admin')) + assert.ok(creative.payload.managed.includes('zonemanager.admin')) +}) + +test('a group travels as a group: its members and its permissions are separate facts (D30)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const { payload, rows } = model.buildDesired('main', authored()) + const vip = payload.groups.find((group) => group.name === 'vip') + + assert.deepEqual(vip.permissions, ['kits.vip']) + assert.deepEqual(vip.members.sort(), ['7656001', '7656099']) + + // Three distinct row kinds, because the game can fail at each independently: a + // group can exist while a membership does not, which is exactly what happens + // for a player the store has never seen. + assert.ok(rows.some((r) => r.kind === 'group' && r.subject === 'vip')) + assert.ok(rows.some((r) => r.kind === 'group-permission' && r.object === 'kits.vip')) + assert.ok(rows.some((r) => r.kind === 'member' && r.object === 'vip')) +}) + +test('the digest does not depend on the order rows came out of the database', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const rows = model.buildDesired('main', authored()).rows + const shuffled = [...rows].reverse() + + // An unsorted digest would differ between two reads of an unchanged set, and + // the loop would push to every game server on every tick for ever. + assert.equal(model.hashRows(rows), model.hashRows(shuffled)) + assert.notEqual(model.hashRows(rows), model.hashRows(rows.slice(1))) +}) + +test('what this site put there and has withdrawn is the only thing retired (D31)', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const desired = [ + { kind: 'grant', subject: '7656001', object: 'kits.gold' }, + { kind: 'member', subject: '7656001', object: 'vip' }, + ] + + const pushed = [ + { kind: 'grant', subject: '7656001', object: 'kits.gold' }, // still wanted + { kind: 'grant', subject: '7656001', object: 'kits.silver' }, // withdrawn + ] + + assert.deepEqual(model.retirements(pushed, desired), [ + { kind: 'grant', subject: '7656001', object: 'kits.silver' }, + ]) + + // A hand grant is in NEITHER set, so it is never retired by this calculation — + // it reaches the operator as drift instead. That difference is the reason the + // pushed ledger exists at all. + assert.deepEqual(model.retirements([], desired), []) +}) + +test('a permission the server could not resolve is not recorded as pushed', async () => { + const queries = withCore() + const permSync = require('../permSync') + + const desired = { + hash: 'h1', + rows: [ + { kind: 'grant', subject: '7656001', object: 'kits.gold' }, + { kind: 'grant', subject: '7656001', object: 'kits.vip' }, + { kind: 'member', subject: '7656002', object: 'vip' }, + { kind: 'member', subject: '7656003', object: 'vip' }, + ], + } + + const report = { + kind: 'perm.report', + applied: { grants: 1 }, + unresolved: ['kits.vip'], + pending: ['7656003:vip'], + foreign: [], + } + + // The catalogue refresh is a second call to the game; stubbed so the report + // path is what this test is about. + const sidecar = require('../sidecarClient') + sidecar.permCatalogue = async () => ({ ok: false, status: 'no-token', data: null }) + + await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null }) + + const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed')) + assert.ok(insert, 'the rows that landed must be recorded') + + const recorded = insert.params.join(' ') + assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed') + assert.ok(!recorded.includes('kits.vip'), 'an unresolved permission never reached the store') + assert.ok(recorded.includes('7656002'), 'a membership that took is pushed') + assert.ok(!recorded.includes('7656003'), 'a pending membership is not in the game yet') +}) + +test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => { + withCore() + const permSync = require('../permSync') + + const base = { + state: 'ok', + dirty: false, + syncedHash: 'h1', + bootId: 'boot-1', + wipeId: 'w-1', + lastAttemptAt: new Date(), + } + + const at = (sync, state = {}) => + permSync.reasonToSync({ + desiredHash: 'h1', + sync, + state: { bootId: 'boot-1', wipeId: 'w-1', ...state }, + force: false, + }) + + assert.equal(at(base), null, 'nothing changed: no push') + assert.equal(at({ ...base, dirty: true }), 'dirty') + assert.equal(permSync.reasonToSync({ desiredHash: 'h2', sync: base, state: {}, force: false }), 'changed') + assert.equal(at(base, { bootId: 'boot-2' }), 'restart') + assert.equal(at(base, { wipeId: 'w-2' }), 'wipe') + assert.equal(at(null), 'first') + + // The audit is the backstop that finds drift on a server nobody has touched. + const old = new Date(Date.now() - permSync.AUDIT_MS - 1000) + assert.equal(at({ ...base, lastAttemptAt: old }), 'audit') +}) + +test('a failing server is left alone for a backoff, unless something changed', () => { + withCore() + const permSync = require('../permSync') + + const failing = { + state: 'failed', + dirty: false, + syncedHash: 'h1', + lastAttemptAt: new Date(), + } + + assert.equal( + permSync.reasonToSync({ desiredHash: 'h1', sync: failing, state: {}, force: false }), + null, + 'a server that just failed is not hammered every thirty seconds', + ) + + assert.equal( + permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, dirty: true }, state: {}, force: false }), + 'retry', + 'an operator changing something is a reason to try again at once', + ) + + const older = new Date(Date.now() - permSync.FAIL_BACKOFF_MS - 1000) + assert.equal( + permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, lastAttemptAt: older }, state: {}, force: false }), + 'retry', + ) +}) + +test('names are lowered, because the store lowers them', () => { + withCore() + const model = require('../model/permissions/permissions.model') + + const set = { + ...authored(), + grants: [{ id: 9, userId: 1, permission: 'Kits.GOLD', scope: '*', steamId: '7656001' }], + } + + const { payload } = model.buildDesired('main', set) + + // Pushed as `kits.gold`, read back as `kits.gold`. Unlowered, the site would + // push one name, find another, and report its own grant as drift for ever. + assert.deepEqual(payload.grants[0].permissions, ['kits.gold']) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index 8b797bb..38e51ad 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -1,5 +1,382 @@ { "paths": { + "/api/v1/admin/rust/permissions": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "The whole permission model", + "description": "Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.", + "responses": { + "200": { + "description": "The authored model and what each game reported", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustPermissionModel" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/catalogue": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "Permission names the servers have registered", + "description": "What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.", + "responses": { + "200": { + "description": "Every registered name, and which servers know it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustPermissionCatalogue" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/drift/{id}/adopt": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Adopt a hand edit", + "description": "Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Adopted" + }, + "400": { + "description": "That kind of drift cannot be adopted" + }, + "404": { + "description": "Not Found" + }, + "409": { + "description": "That Steam account is linked to nobody on this site" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/drift/{id}/revoke": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Revoke a hand edit", + "description": "Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Queued for the next sync" + }, + "404": { + "description": "No such drift" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/grants": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Grant one permission to one person", + "description": "A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.", + "responses": { + "200": { + "description": "They already held it; nothing changed" + }, + "201": { + "description": "Granted" + }, + "400": { + "description": "Invalid body, or a scope naming no configured server" + }, + "404": { + "description": "No account on this site has that name" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permission": { + "example": "any" + }, + "scope": { + "example": "any" + }, + "note": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/grants/{id}": { + "delete": { + "tags": [ + "Admin · Rust" + ], + "summary": "Remove a grant", + "description": "The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Removed" + }, + "404": { + "description": "No such grant" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{name}": { + "put": { + "tags": [ + "Admin · Rust" + ], + "summary": "Create or update a permission group", + "description": "Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Saved" + }, + "400": { + "description": "Invalid body, or a scope naming no configured server" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scope": { + "example": "any" + }, + "title": { + "example": "any" + }, + "rank": { + "example": "any" + }, + "permissions": { + "example": "any" + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Rust" + ], + "summary": "Delete a permission group", + "description": "Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "404": { + "description": "No such group" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{name}/members": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Put an account in a group", + "description": "Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Added" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such group" + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{name}/members/{userId}": { + "delete": { + "tags": [ + "Admin · Rust" + ], + "summary": "Take an account out of a group", + "description": "", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Removed" + }, + "404": { + "description": "No such group, or that account is not in it" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/admin/rust/permissions/sync": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Push the permission set now", + "description": "Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.", + "responses": { + "200": { + "description": "The state of every server after the pass", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustPermissionSyncResult" + } + } + } + }, + "404": { + "description": "No such server" + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "req": { + "example": "any" + } + } + } + } + } + } + } + }, "/api/v1/admin/rust/servers": { "get": { "tags": [ @@ -259,6 +636,167 @@ ] } }, + "/api/v1/admin/users/{id}/rust/permissions": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s Rust privileges (admin only)", + "description": "The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Their groups and grants", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustUserPermissions" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/rust/permissions/grants": { + "post": { + "tags": [ + "Admin · Users" + ], + "summary": "Grant a Rust permission to this user (admin only)", + "description": "Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "They already held it" + }, + "201": { + "description": "Granted" + }, + "400": { + "description": "Invalid body, or a scope naming no configured server", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permission": { + "example": "any" + }, + "scope": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/users/{id}/rust/permissions/grants/{grantId}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Remove a Rust permission from this user (admin only)", + "description": "Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "grantId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The grant to remove." + } + ], + "responses": { + "204": { + "description": "Removed" + }, + "404": { + "description": "No such grant for this user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/player/rust/link": { "post": { "tags": [ @@ -1649,6 +2187,1205 @@ } } }, + "RustPermissionModel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use." + }, + "properties": { + "type": "object", + "properties": { + "groups": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Groups the site authors, mirrored into each in-scope game as a real group." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vip" + } + } + }, + "title": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "VIP" + } + } + }, + "rank": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 10 + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A server id, or `*` for every server." + }, + "example": { + "type": "string", + "example": "*" + } + } + }, + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + } + } + }, + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "wanderer" + } + } + }, + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Null when this account has linked no Steam id, in which case the membership reaches nobody yet." + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "playerName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + } + } + }, + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "wanderer" + } + } + }, + "permission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.gold" + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "What authored it — `admin`, `adopted`, or a later phase’s own writer." + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "note": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": {} + } + }, + "grantedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "accounts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The Steam accounts this grant reaches. Empty means it reaches nobody yet." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The state of the mirror, per configured server." + }, + "items": { + "$ref": "#/components/schemas/RustPermissionSyncState" + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "What a game holds that the site did not author. Reported, never undone." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "One of `grant`, `member`, `group-permission`." + }, + "example": { + "type": "string", + "example": "grant" + } + } + }, + "subject": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A Steam id, or a group name." + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "object": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A permission name, or a group name." + }, + "example": { + "type": "string", + "example": "kits.admin" + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked." + }, + "example": { + "type": "string", + "example": "wanderer" + } + } + }, + "firstSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + } + } + }, + "catalogue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustPermissionCatalogueEntry" + } + } + } + } + } + } + }, + "RustPermissionSyncState": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Whether one server’s store matches what the site authors, and what its last report said." + }, + "properties": { + "type": "object", + "properties": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "state": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "One of `pending`, `ok`, `failed`." + }, + "example": { + "type": "string", + "example": "ok" + } + } + }, + "inSync": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True when the last successful push carried the set the site currently authors." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "dirty": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "lastAttemptAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastOkAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the game’s own refusal." + }, + "example": {} + } + }, + "report": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The plugin’s report from the last successful sync." + }, + "properties": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 2 + } + } + }, + "revokes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "groupsCreated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + } + } + } + } + }, + "alreadyCorrect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 14 + } + } + }, + "unresolved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.gold" + } + } + } + } + }, + "pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Memberships waiting on a first connection: the store has no user record to put in a group yet." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000:vip" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "RustPermissionCatalogue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue)." + }, + "properties": { + "type": "object", + "properties": { + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustPermissionCatalogueEntry" + } + } + } + } + } + } + }, + "RustPermissionCatalogueEntry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One registered permission name, and which servers know it." + }, + "properties": { + "type": "object", + "properties": { + "permission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + }, + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + } + } + } + } + } + } + }, + "RustPermissionSyncResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "What a forced sync produced (POST /admin/rust/permissions/sync)." + }, + "properties": { + "type": "object", + "properties": { + "servers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustPermissionSyncState" + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + } + } + } + } + } + } + } + } + }, + "RustUserPermissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One person’s Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions)." + }, + "properties": { + "type": "object", + "properties": { + "groups": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vip" + } + } + }, + "title": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "VIP" + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "*" + } + } + }, + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + } + } + } + } + } + } + } + } + }, + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + } + } + }, + "permission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.gold" + } + } + }, + "scope": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "main" + } + } + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "grantedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + } + } + }, + "reaches": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + } + } + } + } + } + } + }, "RustSidecarProbe": { "type": "object", "properties": { -- 2.49.1 From f35e70e7d3e3cd091fb9b759006023ff41997b86 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 18:28:58 -0500 Subject: [PATCH 06/51] docs(rust): record why a nested router needs nothing from the fragment generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- server/scripts/swaggerFragment.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/scripts/swaggerFragment.js b/server/scripts/swaggerFragment.js index 511e05e..2b2e0c0 100644 --- a/server/scripts/swaggerFragment.js +++ b/server/scripts/swaggerFragment.js @@ -76,6 +76,15 @@ const SLOT_MOUNT = { 'admin.users.detail': '/api/v1/admin/users/:id', } +// A router mounted INSIDE a registered one with `use()` needs nothing here, and +// that was worth finding out: swagger-autogen reads a FILE and follows its +// `require`s, so `/rust/permissions` is generated with the right prefix from +// `rust.router.js` alone. It is the opposite of the hole phase 6 found with the +// slot — the registration walk cannot see a nested router, and the generator can. +// +// A nested router exists at all because a mount prefix is ONE path segment +// (core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`), so `/rust/permissions` cannot +// be declared in `module.json` and has to be a `use()` under `/rust`. /** * Run `register()` with a recording api and return `[{ file, prefix, what }]`. -- 2.49.1 From e54ae3afb9f78c0886363de4e6e79d82848000b0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 22 Sep 2026 08:55:28 -0500 Subject: [PATCH 07/51] feat(rust): mod configuration from the site, and an editor that will not rewrite a float MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R18's two tiers: a form generated from a config file's own values, and raw JSON for what a form cannot express. Admin → Rust mod config, one live round trip per action, nothing cached between a browser and a game host's disk. `configEdit.js` is the part that could not be done naively. JavaScript cannot tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C# classes — so a read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that then throws at load does not come back. It never parses, mutates and re-serialises: it records the SOURCE SPAN of every value and splices literals into them, so an untouched `1.0` is still `1.0` and a number an admin types travels as text the whole way (D35/D36). The bridge's own config is editable with `Host`, `Port` and `ServerId` locked, in the form and in the raw tier, because either would cut the link carrying the edit or strand every row this site holds (D38). Credentials render masked with a reveal; the raw tier shows them (D37) and the audit trail never does. `rust_config_writes` records every save including the refused and the rolled back — an operator asking why a setting is not what they set needs to see that somebody tried. Three defects a browser walk found that 179 green tests did not: * every save of the bridge's own config was refused while the page said the opposite — a ` onChange(field, event.target.checked)} + /> + ) : ( + onChange(field, event.target.value)} + /> + )} + {field.secret && !field.locked && ( + + )} + + ) +} + +/** What the game said happened. The rollback case is the one worth reading. */ +function Report({ report }) { + if (!report) return null + + const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)' + + return ( +
    +

    + {report.rolledBack + ? 'The plugin did not come back, so the old file was put back automatically.' + : report.reloaded + ? 'Saved, and the plugin reloaded.' + : `Saved. ${report.reason || 'Nothing was reloaded.'}`} +

    + {report.rolledBack && report.reason && ( +

    + {report.reason} +

    + )} + {report.log && ( +
    +          {report.log}
    +        
    + )} + {report.files.some((f) => f.rewritten) && ( + + The plugin rewrote the file as it loaded — both frameworks add any settings a config is + missing and save it back, so what is on disk now is not byte-for-byte what was sent. + + )} +
    + ) +} + +export default function ModConfig() { + const [serverId, setServerId] = useState('') + const [path, setPath] = useState('') + const [tier, setTier] = useState('form') + const [edits, setEdits] = useState({}) + const [raw, setRaw] = useState('') + const [reload, setReload] = useState('') + const [revealed, setRevealed] = useState({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [report, setReport] = useState(null) + const [fileNonce, setFileNonce] = useState(0) + + const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), []) + + // The tree is asked for per server and never cached across one: what is on a + // host's disk has no stale answer worth showing, and a plugin loaded a minute + // ago has to be able to appear. + const { data: tree, error: treeError } = useAsync( + () => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)), + [serverId], + ) + + const { data: file, error: fileError } = useAsync( + () => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)), + [serverId, path, fileNonce], + ) + + const reset = useCallback(() => { + setEdits({}) + setRevealed({}) + setError('') + }, []) + + // A freshly opened file starts from what the host holds: the raw editor's text + // and the reload target's guess both come from the answer rather than from + // whatever the previous file left behind. + // + // **The guess is only taken when the dropdown actually offers it.** A ` setServerId(event.target.value)}> + + {rows.map((row) => ( + + ))} + + {tree && tree.root && ( +

    + {tree.root} + {tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''} +

    + )} + + + {serverId && treeError && } + + {serverId && !treeError && !tree && } + + {tree && ( + + {tree.plugins.length === 0 && ( +

    + This server reports no configuration files. +

    + )} + {tree.plugins.map((group) => ( +
    +
    + + {group.title || group.plugin} + + + {group.loaded ? `loaded · ${group.version}` : 'not loaded'} + {group.isBridge ? ' · this bridge' : ''} + +
    + {group.files.map((entry) => ( +
    + + + {Math.round(entry.bytes / 102.4) / 10} KB + {entry.modified ? ` · changed ${ago(entry.modified)}` : ''} + {entry.reason ? ` · ${entry.reason}` : ''} + +
    + ))} + {!group.loaded && ( + + Nothing on this server is loaded under that name, so a save here is written and + not reloaded. It applies the next time the plugin loads. + + )} +
    + ))} +
    + )} + + {path && fileError && } + {path && !fileError && !file && } + + {file && ( + + + + + } + > + {file.parseError && ( + + This file is not valid JSON on the server ({file.parseError}), so there is nothing to + draw a form from. Raw JSON is the tier that can fix it. + + )} + + {file.isBridge && ( + + This is the bridge’s own configuration. Its address, port and server id are read-only + here — changing any of them from the website would cut the link carrying the change, + or strand every row this site holds for this server. They are editable on the host + itself. This plugin also cannot be reloaded from here. + + )} + + {tier === 'form' && file.fields && ( +
    + {file.fields + .filter((field) => field.path !== '') + .map((field) => ( + setRevealed((current) => ({ ...current, [p]: !current[p] }))} + /> + ))} +
    + )} + + {tier === 'raw' && ( +