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 (
+
+ )
+}
+
+/** 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 (
+
+ )
+}
+
+/** 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 (
+
+ )
+}
+
+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.
+
+
+
+
Join any of our Rust servers and type /link in chat.
+
The server replies with a six-character code, only you can see it, and it lasts five minutes.
+ 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 (
+
+
+
+ )}
+
+ {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
+
+ )}
+
+
+ ))}
+
+
+
+
+ {/* 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. */}
+
+
+ {error && (
+
+ 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.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}
+ )}
+
+
+
+ ))}
+
+
+
+
+ {(data.groups || []).map((group) => (
+
+ ))}
+
+
+
+
+ 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 (
+
+ This account has linked no Steam id, so none of it reaches a game yet. It will apply by
+ itself when they link.
+
+ )}
+
+
+
+ {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 `
)}
+
+ {/* Phase 8. Rendered whether or not anything is linked: an entitlement is
+ authored against the website account, so it exists before a Steam id
+ does — and hiding it until one appears is the mistake the admin user
+ page shipped in phase 7 (PLAN.md §20.5). */}
+
What you can do in game
+
+ {data && }
)
}
diff --git a/routes.manifest.json b/routes.manifest.json
index 7b7e2b6..d9eaa66 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -81,6 +81,11 @@
"path": "/api/v1/player/rust/links",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/player/rust/permissions",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/player/rust/servers",
diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js
index de97b05..d33ca48 100644
--- a/server/model/permissions/permissions.db.js
+++ b/server/model/permissions/permissions.db.js
@@ -213,6 +213,46 @@ async function listLinks() {
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
}
+// ---- one person's own half of all of it (the player tier) ----
+//
+// Every read below is scoped inside the statement rather than filtered after it.
+// The admin reads above answer "who holds what"; these answer "what do I hold",
+// and the difference between the two is a `WHERE` that must not be somebody
+// else's job to remember.
+
+/** The groups one website user belongs to. Ordered the way the admin list is. */
+async function listGroupsForUser(userId) {
+ return core.query(
+ `SELECT g.name, g.title, g.\`rank\`, g.scope, m.added_at AS addedAt
+ FROM ${GROUP_MEMBERS} m
+ JOIN ${GROUPS} g ON g.name = m.group_name
+ WHERE m.user_id = ?
+ ORDER BY g.\`rank\` DESC, g.name ASC`,
+ [userId],
+ )
+}
+
+/**
+ * Every pushed row naming one of these Steam ids, across every server.
+ *
+ * The pushed ledger is keyed by Steam id because it records what is in a GAME
+ * (D28's other half), so this is the one read in the file that starts from an
+ * account rather than from a user. `kind` is carried through: a direct grant and
+ * a group membership are different rows about the same person and only the
+ * caller can say which of them it was looking for.
+ */
+async function listPushedForSteamIds(steamIds) {
+ if (!steamIds.length) return []
+
+ return core.query(
+ `SELECT server_id AS serverId, kind, subject, object
+ FROM ${PUSHED}
+ WHERE subject IN (${steamIds.map(() => '?').join(',')})
+ AND kind IN ('grant', 'member')`,
+ steamIds,
+ )
+}
+
// ---- what is actually out there ----
async function listPushed(serverId) {
@@ -440,6 +480,8 @@ module.exports = {
deleteGrant,
findUserByUsername,
listLinks,
+ listGroupsForUser,
+ listPushedForSteamIds,
listPushed,
addPushed,
removePushed,
diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js
index 5412fd1..db85b18 100644
--- a/server/model/permissions/permissions.model.js
+++ b/server/model/permissions/permissions.model.js
@@ -182,6 +182,94 @@ function catalogueByPermission(rows) {
.sort((a, b) => a.permission.localeCompare(b.permission))
}
+/**
+ * ── What one person holds, as that person reads it ────────────────────────
+ *
+ * The admin overview answers *who holds what*; this answers *what do I hold*,
+ * and it is a different shape rather than a filtered one. Three things make it
+ * different:
+ *
+ * 1. **The scope arithmetic is answered here, not sent.** A client handed
+ * `scope: '*'` would have to know what the fleet is and re-implement
+ * `inScope` to say anything useful, and then there would be two of it. Each
+ * entry carries the servers it actually reaches, already resolved.
+ * 2. **`live` is per server and it is the pushed ledger, not the authored
+ * row.** A grant made on the website is not a privilege in a game until a
+ * sync confirmed it, and phase 7 is careful never to record a push that
+ * silently did nothing (an unregistered permission, a store that has never
+ * seen the player). So "waiting" here means waiting, and saying otherwise
+ * would be the site claiming to have given something it has not.
+ * 3. **Nothing says WHY it is waiting.** Which permission names a server's
+ * loaded plugins registered is an operator's diagnosis and an inventory of
+ * what is installed; a player gets the honest state, not the reason.
+ *
+ * Every read is scoped to the caller in SQL, and the pushed rows are looked up
+ * by the caller's OWN Steam ids — so a person with no linked account correctly
+ * sees entitlements that reach nobody yet, rather than nothing at all (the
+ * mistake phase 7 shipped on the admin user page, §20.5).
+ */
+async function forPlayer(userId, steamIds, serverRows) {
+ const [groups, groupPermissions, grants, pushed] = await Promise.all([
+ db.listGroupsForUser(userId),
+ db.listGroupPermissions(),
+ db.listGrants({ userId }),
+ db.listPushedForSteamIds(steamIds),
+ ])
+
+ const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id }))
+
+ // `kind:object` -> the servers a row of ours landed on. The subject is one of
+ // this caller's own Steam ids by construction, so it does not enter the key:
+ // an entitlement is live for the person if it is live for any account they
+ // hold, which is the same thing the game sees.
+ const live = new Map()
+
+ for (const row of pushed) {
+ const key = `${row.kind}:${normaliseName(row.object)}`
+ if (!live.has(key)) live.set(key, new Set())
+ live.get(key).add(row.serverId)
+ }
+
+ /** The servers a scope reaches, each marked with whether it is there yet. */
+ function reach(scope, key) {
+ const landed = live.get(key) || new Set()
+
+ return servers
+ .filter((server) => inScope(scope, server.id))
+ .map((server) => ({ ...server, live: landed.has(server.id) }))
+ }
+
+ const permissionsByGroup = new Map()
+
+ for (const row of groupPermissions) {
+ if (!permissionsByGroup.has(row.groupName)) permissionsByGroup.set(row.groupName, [])
+ permissionsByGroup.get(row.groupName).push(normaliseName(row.permission))
+ }
+
+ return {
+ groups: groups.map((group) => ({
+ name: group.name,
+ title: group.title || group.name,
+ scope: group.scope,
+ since: group.addedAt,
+ permissions: (permissionsByGroup.get(group.name) || []).sort(),
+ reach: reach(group.scope, `member:${normaliseName(group.name)}`),
+ })),
+ // `collapseGrants` first: the join multiplies a grant by the accounts its
+ // holder has linked, and this caller may hold two.
+ grants: collapseGrants(grants)
+ .map((grant) => ({
+ permission: grant.permission,
+ scope: grant.scope,
+ source: grant.source,
+ note: grant.note,
+ since: grant.grantedAt,
+ reach: reach(grant.scope, `grant:${normaliseName(grant.permission)}`),
+ }))
+ .sort((a, b) => a.permission.localeCompare(b.permission)),
+ }
+}
+
/**
* The whole authored set, read once, in the shape the per-server build wants.
*
@@ -346,6 +434,7 @@ module.exports = {
normaliseName,
inScope,
overview,
+ forPlayer,
readAuthored,
buildDesired,
retirements,
diff --git a/server/router/player/rust.controller.js b/server/router/player/rust.controller.js
index 77161d2..49b4bda 100644
--- a/server/router/player/rust.controller.js
+++ b/server/router/player/rust.controller.js
@@ -22,6 +22,7 @@
const core = require('../../core')
const links = require('../../model/links/links.model')
+const permissions = require('../../model/permissions/permissions.model')
const servers = require('../../model/servers/servers.model')
const log = core.logger('player')
@@ -134,4 +135,37 @@ async function removeLink(req, res) {
}
}
-module.exports = { listServers, listLinks, confirmLink, removeLink }
+/**
+ * GET /player/rust/permissions — what the site has given this player in game.
+ *
+ * Phase 7 made the website the author of in-game privilege and gave an operator
+ * every view of it; this is the other side of that, and it is the first time a
+ * player can see what they hold without asking one. Read-only by construction:
+ * nothing a player can do here changes a grant, because a grant they could
+ * change would not be a grant.
+ *
+ * The caller's Steam ids come from the link model rather than the permission
+ * one, so the two questions stay in the files that own them — and the pushed
+ * ledger is keyed by Steam id, which is the whole reason this read needs them.
+ */
+async function listPermissions(req, res) {
+ try {
+ const [accounts, serverRows] = await Promise.all([
+ links.listForUser(req.user.id),
+ servers.listPublic(),
+ ])
+
+ const held = await permissions.forPlayer(
+ req.user.id,
+ accounts.map((account) => account.steamId),
+ serverRows,
+ )
+
+ res.json({ ...held, accounts: accounts.length })
+ } catch (err) {
+ log.error('failed to read a player’s entitlements', { error: err.message })
+ res.status(500).json({ message: 'Failed to read what you hold in game' })
+ }
+}
+
+module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions }
diff --git a/server/router/player/rust.router.js b/server/router/player/rust.router.js
index ad5195c..ebec987 100644
--- a/server/router/player/rust.router.js
+++ b/server/router/player/rust.router.js
@@ -77,6 +77,16 @@ playerRustRouter.get(
rust.listLinks,
)
+playerRustRouter.get(
+ '/permissions',
+ // #swagger.tags = ['Player · Rust']
+ // #swagger.summary = 'What the site has given the caller in game'
+ // #swagger.description = 'The groups and direct grants the site holds for the signed-in user, each resolved to the servers its scope reaches and marked with whether that server has it yet. Read-only: a grant a player could change would not be a grant. `live` is the pushed ledger rather than the authored row, so an entitlement that has not reached a game reads as waiting — which is also what an offline server, a permission no loaded plugin registered, and an account the store has never seen all look like from here.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ /* #swagger.responses[200] = { description: 'What the caller holds', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPlayerPermissions" } } } } */
+ rust.listPermissions,
+)
+
playerRustRouter.post(
'/link',
// #swagger.tags = ['Player · Rust']
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index 27fa245..fd20563 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -127,6 +127,58 @@ module.exports = {
links: { type: 'array', items: { $ref: '#/components/schemas/RustLink' } },
},
},
+ RustPlayerReach: {
+ type: 'object',
+ description: 'One server an entitlement’s scope reaches, and whether it is there yet.',
+ properties: {
+ id: { type: 'string', example: 'main' },
+ name: { type: 'string', example: 'Main · Vanilla+' },
+ live: {
+ type: 'boolean',
+ description: 'True only when a sync confirmed this into that server’s own store. False covers every way it has not arrived — the server is offline, no loaded plugin registered the name, or its store has never seen the account — and the difference between those is an operator’s diagnosis, not a player’s.',
+ example: true,
+ },
+ },
+ },
+ RustPlayerPermissions: {
+ type: 'object',
+ description: 'What the site has given the signed-in player in game (GET /player/rust/permissions).',
+ properties: {
+ accounts: {
+ type: 'integer',
+ description: 'How many Steam accounts the caller has linked. Zero is why an entitlement can be authored and reach nobody.',
+ example: 1,
+ },
+ groups: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', example: 'vip' },
+ title: { type: 'string', example: 'VIP' },
+ scope: { type: 'string', description: 'A server id, or `*` for the whole fleet.', example: '*' },
+ since: { type: 'string', format: 'date-time' },
+ permissions: { type: 'array', items: { type: 'string' }, example: ['kits.vip'] },
+ reach: { type: 'array', items: { $ref: '#/components/schemas/RustPlayerReach' } },
+ },
+ },
+ },
+ grants: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ permission: { type: 'string', example: 'kits.vip' },
+ scope: { type: 'string', example: '*' },
+ source: { type: 'string', description: 'Who authored it — `admin` now, an event action later.', example: 'admin' },
+ note: { type: 'string', nullable: true },
+ since: { type: 'string', format: 'date-time' },
+ reach: { type: 'array', items: { $ref: '#/components/schemas/RustPlayerReach' } },
+ },
+ },
+ },
+ },
+ },
RustLinkRequest: {
type: 'object',
required: ['code'],
diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js
index 12a2851..8a6e61f 100644
--- a/server/test/identityRoutes.test.js
+++ b/server/test/identityRoutes.test.js
@@ -37,13 +37,21 @@ function routesOf(router) {
}))
}
-test('the player tier serves the three identity routes, and nothing else new', () => {
+test('the player tier serves the identity routes, the entitlement read, and nothing else', () => {
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'],
+ [
+ 'DELETE /links/:steamId',
+ 'GET /links',
+ // Phase 8: what the site has given the caller in game. Read-only on this
+ // tier by construction — the authoring routes are all admin.
+ 'GET /permissions',
+ 'GET /servers',
+ 'POST /link',
+ ],
)
})
diff --git a/server/test/playerPermissions.test.js b/server/test/playerPermissions.test.js
new file mode 100644
index 0000000..f0d0db2
--- /dev/null
+++ b/server/test/playerPermissions.test.js
@@ -0,0 +1,171 @@
+// ── What one player holds, as that player reads it ────────────────────────
+//
+// Phase 8's half of R2. The admin surface answers *who holds what* against the
+// authored tables; this answers *what do I hold*, and the two differ in three
+// ways that are each a test below:
+//
+// • the scope is RESOLVED here. A client handed `*` would have to know what
+// the fleet is to say anything, and then `inScope` exists twice.
+// • `live` is the PUSHED ledger, never the authored row. A grant is not a
+// privilege in a game until a sync confirmed it, and phase 7 is careful
+// never to record a push that silently did nothing — so "waiting" is an
+// honest answer and the alternative is the site claiming to have given
+// something it has not.
+// • an entitlement reaching NOBODY still lists. Authored against the website
+// account, it exists before a Steam id does, and hiding it until one turns
+// up is the defect the admin user page shipped in phase 7 (PLAN.md §20.5).
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+/** The model, wired to a db module answering from one fixture. */
+function modelWith(fixture) {
+ require('../core')._reset()
+ require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
+
+ const db = require('../model/permissions/permissions.db')
+ const model = require('../model/permissions/permissions.model')
+
+ const originals = {}
+ for (const [name, value] of Object.entries(fixture)) {
+ originals[name] = db[name]
+ db[name] = () => Promise.resolve(value)
+ }
+
+ return { model, restore: () => Object.assign(db, originals) }
+}
+
+const SERVERS = [
+ { id: 'main', name: 'Main' },
+ { id: 'creative', name: 'Creative' },
+]
+
+/** One person: in a fleet group, holding one server-scoped grant. */
+function fixture({ pushed = [] } = {}) {
+ return {
+ listGroupsForUser: [{ name: 'vip', title: 'VIP', rank: 10, scope: '*', addedAt: '2026-09-01T00:00:00Z' }],
+ listGroupPermissions: [
+ { groupName: 'vip', permission: 'Kits.VIP' },
+ { groupName: 'builder', permission: 'buildtools.use' },
+ ],
+ listGrants: [
+ { id: 7, userId: 4, permission: 'zonemanager.admin', scope: 'creative', source: 'admin', note: null, grantedAt: '2026-09-02T00:00:00Z', steamId: '7656119', playerName: 'Wanderer' },
+ ],
+ listPushedForSteamIds: pushed,
+ }
+}
+
+test('a fleet scope resolves to every server; a server scope to one', async () => {
+ const { model, restore } = modelWith(fixture())
+
+ try {
+ const held = await model.forPlayer(4, ['7656119'], SERVERS)
+
+ assert.deepEqual(held.groups[0].reach.map((s) => s.id), ['main', 'creative'])
+ assert.deepEqual(held.grants[0].reach.map((s) => s.id), ['creative'])
+ } finally {
+ restore()
+ }
+})
+
+test('live is the pushed ledger, per server — not the authored row', async () => {
+ const { model, restore } = modelWith(
+ fixture({ pushed: [{ serverId: 'main', kind: 'member', subject: '7656119', object: 'vip' }] }),
+ )
+
+ try {
+ const held = await model.forPlayer(4, ['7656119'], SERVERS)
+ const byId = Object.fromEntries(held.groups[0].reach.map((s) => [s.id, s.live]))
+
+ assert.equal(byId.main, true, 'the server that confirmed it has it')
+ assert.equal(byId.creative, false, 'the one that has not is waiting, not live')
+
+ // The grant was never pushed anywhere, and an authored row must not imply one.
+ assert.deepEqual(held.grants[0].reach.map((s) => s.live), [false])
+ } finally {
+ restore()
+ }
+})
+
+test('an entitlement is live for the person when it landed on ANY account they hold', async () => {
+ // Two accounts, one membership pushed against the second. The game sees one
+ // player with the rank; so does this.
+ const { model, restore } = modelWith(
+ fixture({ pushed: [{ serverId: 'main', kind: 'member', subject: '7656120', object: 'vip' }] }),
+ )
+
+ try {
+ const held = await model.forPlayer(4, ['7656119', '7656120'], SERVERS)
+
+ assert.equal(held.groups[0].reach.find((s) => s.id === 'main').live, true)
+ } finally {
+ restore()
+ }
+})
+
+test('a grant and a membership are different rows about the same person', async () => {
+ // `kind` is why the pushed lookup carries it: a membership of `vip` and a
+ // direct grant named `vip` would otherwise be one entry in the map, and the
+ // wrong one would light up.
+ const { model, restore } = modelWith({
+ listGroupsForUser: [{ name: 'vip', title: 'VIP', rank: 0, scope: '*', addedAt: null }],
+ listGroupPermissions: [],
+ listGrants: [{ id: 1, userId: 4, permission: 'vip', scope: '*', source: 'admin', note: null, grantedAt: null, steamId: '7656119' }],
+ listPushedForSteamIds: [{ serverId: 'main', kind: 'grant', subject: '7656119', object: 'vip' }],
+ })
+
+ try {
+ const held = await model.forPlayer(4, ['7656119'], SERVERS)
+
+ assert.equal(held.grants[0].reach.find((s) => s.id === 'main').live, true)
+ assert.equal(held.groups[0].reach.find((s) => s.id === 'main').live, false)
+ } finally {
+ restore()
+ }
+})
+
+test('a player with no linked account still sees what they were given', async () => {
+ const { model, restore } = modelWith(fixture())
+
+ try {
+ const held = await model.forPlayer(4, [], SERVERS)
+
+ assert.equal(held.groups.length, 1)
+ assert.equal(held.grants.length, 1)
+ assert.ok(
+ [...held.groups[0].reach, ...held.grants[0].reach].every((s) => s.live === false),
+ 'authored, and reaching nobody — which is the state worth showing',
+ )
+ } finally {
+ restore()
+ }
+})
+
+test('only the caller’s own groups carry their permissions, lowered as the store lowers them', async () => {
+ const { model, restore } = modelWith(fixture())
+
+ try {
+ const held = await model.forPlayer(4, ['7656119'], SERVERS)
+
+ // `builder`'s permission is in the group-permission table and this caller is
+ // not in that group; `Kits.VIP` is theirs, and arrives the way a game stores it.
+ assert.deepEqual(held.groups[0].permissions, ['kits.vip'])
+ } finally {
+ restore()
+ }
+})
+
+test('a fleet with no servers configured reaches nothing and does not throw', async () => {
+ const { model, restore } = modelWith(fixture())
+
+ try {
+ const held = await model.forPlayer(4, ['7656119'], [])
+
+ assert.deepEqual(held.groups[0].reach, [])
+ assert.deepEqual(held.grants[0].reach, [])
+ } finally {
+ restore()
+ }
+})
diff --git a/swagger-fragment.json b/swagger-fragment.json
index d95975b..a933c8d 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -1128,6 +1128,38 @@
]
}
},
+ "/api/v1/player/rust/permissions": {
+ "get": {
+ "tags": [
+ "Player · Rust"
+ ],
+ "summary": "What the site has given the caller in game",
+ "description": "The groups and direct grants the site holds for the signed-in user, each resolved to the servers its scope reaches and marked with whether that server has it yet. Read-only: a grant a player could change would not be a grant. `live` is the pushed ledger rather than the authored row, so an entitlement that has not reached a game reads as waiting — which is also what an offline server, a permission no loaded plugin registered, and an account the store has never seen all look like from here.",
+ "responses": {
+ "200": {
+ "description": "What the caller holds",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustPlayerPermissions"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/player/rust/servers": {
"get": {
"tags": [
@@ -1950,6 +1982,324 @@
}
}
},
+ "RustPlayerReach": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One server an entitlement’s scope reaches, and whether it is there yet."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Main · Vanilla+"
+ }
+ }
+ },
+ "live": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "True only when a sync confirmed this into that server’s own store. False covers every way it has not arrived — the server is offline, no loaded plugin registered the name, or its store has never seen the account — and the difference between those is an operator’s diagnosis, not a player’s."
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustPlayerPermissions": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "What the site has given the signed-in player in game (GET /player/rust/permissions)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "accounts": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "How many Steam accounts the caller has linked. Zero is why an entitlement can be authored and reach nobody."
+ },
+ "example": {
+ "type": "number",
+ "example": 1
+ }
+ }
+ },
+ "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"
+ },
+ "description": {
+ "type": "string",
+ "example": "A server id, or `*` for the whole fleet."
+ },
+ "example": {
+ "type": "string",
+ "example": "*"
+ }
+ }
+ },
+ "since": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "permissions": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "example": {
+ "type": "array",
+ "example": [
+ "kits.vip"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "reach": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustPlayerReach"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "grants": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "permission": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "kits.vip"
+ }
+ }
+ },
+ "scope": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "*"
+ }
+ }
+ },
+ "source": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "Who authored it — `admin` now, an event action later."
+ },
+ "example": {
+ "type": "string",
+ "example": "admin"
+ }
+ }
+ },
+ "note": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "since": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "reach": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustPlayerReach"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"RustLinkRequest": {
"type": "object",
"properties": {
--
2.49.1
From c4dda5f85c73b3bdd8c028af64069b793c02039a Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 22 Sep 2026 20:32:34 -0500
Subject: [PATCH 09/51] fix(rust): put the word on the pill, not only the dot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A filled circle beside a hollow one is the whole difference between "you
have this in game" and "you do not yet", which is more than a shape should
have to carry — and a reader who cannot tell the two apart gets no answer
at all. The pill now reads " · has it" or " · waiting",
which is also what the app's leg says, so the two surfaces describe the
same state in the same words.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
---
client/src/routes/player/Account.jsx | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/client/src/routes/player/Account.jsx b/client/src/routes/player/Account.jsx
index 8608ce4..7465233 100644
--- a/client/src/routes/player/Account.jsx
+++ b/client/src/routes/player/Account.jsx
@@ -151,17 +151,20 @@ function Reach({ reach }) {
{reach.map((server) => (
+ {/* The word, not only the dot. A filled circle beside a hollow one is
+ the whole difference between "you have this in game" and "you do
+ not yet", which is more than a shape should have to carry — and a
+ reader who cannot tell the two apart gets no answer at all. */}
{server.live ? '● ' : '○ '}
- {server.name}
+ {server.name} · {server.live ? 'has it' : 'waiting'}
))}
@@ -250,7 +253,7 @@ function Held({ accounts }) {
{accounts > 0 && waiting && (
- A hollow dot is a server that has not confirmed it yet. One that is offline catches up
+ A server marked waiting has not confirmed it yet. One that is offline catches up
when it comes back.
)}
--
2.49.1
From be448398960015b4901f1ce0ca17bb7aaffb8657 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 23 Sep 2026 00:30:08 -0500
Subject: [PATCH 10/51] fix(rust): nothing names who is online by default
The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.
The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:
* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
Below the audience the feed keeps the server's own story (wipe, start,
shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
rust_settings table with an optional per-server override on
rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
not taken from the token, so a demotion or a ban applies on the next
request. Walked: a moderator demoted mid-session lost the roll call on
the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
Rust visibility; every save is one activity-log row.
The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
README.md | 8 +
client/src/api.js | 12 +
client/src/components/Empty.jsx | 26 ++
client/src/components/Feed.jsx | 22 +-
client/src/components/Leaderboard.jsx | 19 +-
client/src/components/Online.jsx | 32 +-
client/src/components/Wipes.jsx | 5 +-
client/src/entry.jsx | 8 +-
client/src/icons.jsx | 14 +-
client/src/routes/admin/Visibility.jsx | 196 ++++++++
client/src/routes/public/Servers.jsx | 5 +-
client/test/uiKitProps.test.js | 49 ++
routes.manifest.json | 10 +
server/catalogue.js | 46 +-
server/core.js | 8 +
server/db/purge.sql | 1 +
server/db/schema.sql | 33 ++
server/model/events/events.model.js | 12 +-
server/model/visibility/visibility.db.js | 55 +++
server/model/visibility/visibility.model.js | 207 +++++++++
server/router/admin/rust.router.js | 5 +
server/router/admin/visibility.controller.js | 39 ++
server/router/admin/visibility.router.js | 51 +++
server/router/public/rust.controller.js | 55 ++-
server/router/public/rust.router.js | 8 +-
server/swagger/doc.js | 71 +++
server/test/_fakes.js | 3 +
server/test/catalogue.test.js | 35 +-
server/test/events.test.js | 30 +-
server/test/visibility.test.js | 262 +++++++++++
swagger-fragment.json | 445 ++++++++++++++++++-
31 files changed, 1739 insertions(+), 33 deletions(-)
create mode 100644 client/src/components/Empty.jsx
create mode 100644 client/src/routes/admin/Visibility.jsx
create mode 100644 client/test/uiKitProps.test.js
create mode 100644 server/model/visibility/visibility.db.js
create mode 100644 server/model/visibility/visibility.model.js
create mode 100644 server/router/admin/visibility.controller.js
create mode 100644 server/router/admin/visibility.router.js
create mode 100644 server/test/visibility.test.js
diff --git a/README.md b/README.md
index 439adfa..704cffa 100644
--- a/README.md
+++ b/README.md
@@ -42,10 +42,18 @@ rows here; the website core never learns there is more than one.
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
+| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
| Slot | `site.footer.status` — a live server/player count in core's footer |
+**Nothing names who is online by default.** The Online list, every feed item that says a named
+player was on the server (connects, respawns, deaths, chat, gather tallies) and the leaderboard's
+"last seen" reach **staff** unless an operator widens them in Admin → Rust visibility — fleet-wide,
+with an optional override per server. How many players are online is public at every setting. The
+viewer's standing is re-read from the database on each request, so a demotion or a ban applies at
+once rather than when a token expires.
+
Every page reads this module's own tables and never calls a game server, which is what lets the
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
sort all live in the URL, so any view of it is a link.
diff --git a/client/src/api.js b/client/src/api.js
index c0747fc..28df9ac 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -154,6 +154,17 @@ export const adminPermissions = {
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
}
+// ── admin · visibility ────────────────────────────────────────────────────
+//
+// Who may see who is online. The org lead's rule is that nothing names who is
+// online by default; this is where an operator deliberately widens it. A save
+// answers the whole new state, so the screen re-renders from the server's word
+// rather than from what it sent.
+export const adminVisibility = {
+ read: () => req('/admin/rust/visibility'),
+ save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }),
+}
+
// ── admin · mod configuration (R18) ───────────────────────────────────────
//
// Every call here is a LIVE round trip to a game host, which makes this the only
@@ -226,6 +237,7 @@ export default {
admin,
adminPermissions,
adminConfig,
+ adminVisibility,
adminUserLinks,
adminUserPermissions,
BASE,
diff --git a/client/src/components/Empty.jsx b/client/src/components/Empty.jsx
new file mode 100644
index 0000000..fa58e94
--- /dev/null
+++ b/client/src/components/Empty.jsx
@@ -0,0 +1,26 @@
+// ── An empty state with a heading and a sentence ──────────────────────────
+//
+// Core's `EmptyState` renders its CHILDREN and nothing else. This module passed
+// it `title` and `message` from phase 4 onwards — the shape the Integration Kit's
+// template teaches — and React drops an unknown prop without a word, so every
+// empty panel in the module rendered as a blank box: "Nobody is on", "No scores
+// yet", "No servers yet", all of them. Found by the presence fix's browser walk,
+// when the "12 players online" it depended on came out as nothing.
+//
+// Fixed here rather than in core: core's component is shared by every module,
+// and a module-side wrapper changes nothing anybody else renders. The client
+// suite (`test/uiKitProps.test.js`) refuses a titled EmptyState so the mistake
+// cannot come back.
+
+import { EmptyState } from '../core.js'
+
+export default function Empty({ title, message }) {
+ return (
+
+ {title && (
+ {title}
+ )}
+ {message && {message}}
+
+ )
+}
diff --git a/client/src/components/Feed.jsx b/client/src/components/Feed.jsx
index 362ba0e..62618c6 100644
--- a/client/src/components/Feed.jsx
+++ b/client/src/components/Feed.jsx
@@ -11,11 +11,13 @@
// see the comment at the top of that file for why core's `useAsync` cannot do
// this job.
-import { EmptyState, ErrorState, Loading } from '../core.js'
+import { ErrorState, Loading } from '../core.js'
+import Empty from './Empty.jsx'
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
import { ago, clock } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
+import { hiddenMessage } from './Online.jsx'
const TONE = {
kill: 'var(--accent-bright)',
@@ -79,10 +81,24 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
off" must not blank itself the first time a request does. */}
{error && !data && }
+ {/* Below the operator's presence audience the server withholds every item
+ that names a player who was on — the killfeed, chat, joins — and keeps
+ only the server's own story. Said once, above the rows, so a thin feed
+ reads as withheld rather than as a quiet server. */}
+ {data && data.presenceHidden && (
+
+ Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
+
+ )}
+
{data && events.length === 0 && (
-
)}
diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx
index cf386dc..02eff97 100644
--- a/client/src/components/Leaderboard.jsx
+++ b/client/src/components/Leaderboard.jsx
@@ -10,7 +10,8 @@
// than one that is four minutes old, and the page has a `Refresh` on the tab
// strip for anybody who disagrees.
-import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
+import { ErrorState, Loading, useAsync } from '../core.js'
+import Empty from './Empty.jsx'
import { ago, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
@@ -32,13 +33,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
)
const rows = data ? data.leaderboard : []
+ // Present on every row or on none — the server decides per request.
+ const showLastSeen = rows.some((row) => 'lastSeen' in row)
if (loading) return
if (error) return
if (rows.length === 0) {
return (
-
))}
-
Last seen
+ {/* The server withholds `lastSeen` below the operator's presence
+ audience — a gather tally refreshes it every minute somebody plays,
+ so it would name who is online. The column goes with it rather
+ than rendering a row of dashes that look like "never". */}
+ {showLastSeen && (
+
+ )}
))}
diff --git a/client/src/components/Online.jsx b/client/src/components/Online.jsx
index f725fb0..3addc64 100644
--- a/client/src/components/Online.jsx
+++ b/client/src/components/Online.jsx
@@ -9,7 +9,8 @@
// It polls with the feed, because "who is on" is the one thing on this page that
// is a live question.
-import { EmptyState, ErrorState, Loading } from '../core.js'
+import { ErrorState, Loading } from '../core.js'
+import Empty from './Empty.jsx'
import { duration, shortId } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
@@ -25,9 +26,23 @@ export default function Online({ serverId, online }) {
if (loading) return
if (error && !data) return
+ // Nothing names who is online by default (the org lead's rule). Below the
+ // operator's audience the server answers a count and no names, and the page
+ // says so — an empty list here would read as "nobody is on", which is a
+ // different claim and a false one.
+ if (data && data.hidden) {
+ const count = Number(data.count) || 0
+ return (
+
+ )
+ }
+
if (players.length === 0) {
return (
-
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index 81dd701..0c6950d 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -23,9 +23,10 @@ import ServerDetail from './routes/public/ServerDetail.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
+import Visibility from './routes/admin/Visibility.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
-import { IconKey, IconLink, IconSliders } from './icons.jsx'
+import { IconEye, IconKey, IconLink, IconSliders } 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.
@@ -94,6 +95,10 @@ registry.registerRoutes(ID, {
// `/admin/rust/config` and core's admin gate applies to it exactly as it
// does to the page above.
{ path: 'config', element: },
+ // Who may see who is online — a third neighbour. The org lead's rule is that
+ // nothing names who is online by default; this is where an operator widens
+ // it on purpose, fleet-wide or per server.
+ { path: 'visibility', element: },
],
})
@@ -142,6 +147,7 @@ registry.registerNav(ID, {
items: [
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
+ { label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
],
})
diff --git a/client/src/icons.jsx b/client/src/icons.jsx
index f506bca..f8e90ae 100644
--- a/client/src/icons.jsx
+++ b/client/src/icons.jsx
@@ -83,4 +83,16 @@ export const IconSliders = () => (
)
-export default { IconLink, IconKey, IconSliders }
+/**
+ * An eye — the admin sidebar's row for who may see who is online.
+ *
+ * The page decides what the public can SEE, so the glyph is the act of seeing.
+ */
+export const IconEye = () => (
+
+
+
+
+)
+
+export default { IconLink, IconKey, IconSliders, IconEye }
diff --git a/client/src/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx
new file mode 100644
index 0000000..3671adb
--- /dev/null
+++ b/client/src/routes/admin/Visibility.jsx
@@ -0,0 +1,196 @@
+// ── Admin · Rust · Visibility ─────────────────────────────────────────────
+//
+// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
+// is online by default — the narrowest audience, staff, unless an operator
+// deliberately widens it here. A count of players is public at every setting.
+//
+// One fleet default and an optional override per server, because a creative or
+// PvE server may reasonably publish a roll call a PvP server must not — and a
+// server that has not chosen follows the fleet, so narrowing the fleet narrows
+// every server that never said otherwise.
+//
+// The page says what "who is online" covers, because it is wider than the tab
+// of the same name: the killfeed, chat and joins in the feed, and the
+// leaderboard's "last seen" all name a player who was on at a given moment.
+
+import { useCallback, useEffect, useState } from 'react'
+
+import { ErrorState, Loading, useAsync } from '../../core.js'
+import api from '../../api.js'
+
+const INHERIT = ''
+
+const LABEL = {
+ staff: 'Staff only',
+ signed_in: 'Signed-in members',
+ public: 'Everyone',
+}
+
+const DESCRIBE = {
+ staff: 'Admins and moderators. The default.',
+ signed_in: 'Anybody with an account on this site.',
+ public: 'Anybody at all, signed in or not.',
+}
+
+function Card({ title, subtitle, children }) {
+ return (
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {children}
+
+ )
+}
+
+function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
+ return (
+
+ )
+}
+
+export default function Visibility() {
+ const [reloads, setReloads] = useState(0)
+ const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
+
+ const [fleet, setFleet] = useState('staff')
+ const [servers, setServers] = useState({})
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [saved, setSaved] = useState(false)
+
+ // The form starts from what the server said and is reset from it after every
+ // save — the answer to a PUT is the new state, so what is on screen is always
+ // the site's word rather than what this page sent.
+ const load = useCallback((state) => {
+ setFleet(state.presence.fleet)
+ setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
+ }, [])
+
+ useEffect(() => {
+ if (data) load(data)
+ }, [data, load])
+
+ if (loadError) return
+ if (!data) return
+
+ const audiences = data.audiences
+ const rows = data.presence.servers
+
+ const dirtyFleet = fleet !== data.presence.fleet
+ const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
+ const dirty = dirtyFleet || dirtyServers.length > 0
+
+ const effective = (id) => servers[id] || fleet
+ const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
+
+ const save = async (e) => {
+ e.preventDefault()
+ setBusy(true)
+ setError('')
+ setSaved(false)
+ try {
+ const body = {}
+ if (dirtyFleet) body.fleet = fleet
+ if (dirtyServers.length) {
+ body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
+ }
+ load(await api.adminVisibility.save(body))
+ setSaved(true)
+ setReloads((n) => n + 1)
+ } catch (err) {
+ setError(err.message || 'That did not save.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+const selectStyle = {
+ background: 'var(--panel-flat, transparent)',
+ color: 'var(--text)',
+ border: '1px solid var(--line)',
+ borderRadius: 'var(--radius-input, 6px)',
+ padding: '4px 8px',
+ fontSize: '0.84rem',
+}
diff --git a/client/src/routes/public/Servers.jsx b/client/src/routes/public/Servers.jsx
index 46318fa..bac1f41 100644
--- a/client/src/routes/public/Servers.jsx
+++ b/client/src/routes/public/Servers.jsx
@@ -22,7 +22,8 @@
// is down. The site's availability does not depend on the game's.
import { Link } from 'react-router-dom'
-import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
+import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
+import Empty from '../../components/Empty.jsx'
import { ago, count, day } from '../../lib/format.js'
import api from '../../api.js'
@@ -61,7 +62,7 @@ export default function Servers() {
empty game — it is an install that is not finished. Saying so beats a
blank page that looks like a failure. */}
{data && servers.length === 0 && (
-
diff --git a/client/test/uiKitProps.test.js b/client/test/uiKitProps.test.js
new file mode 100644
index 0000000..876a82b
--- /dev/null
+++ b/client/test/uiKitProps.test.js
@@ -0,0 +1,49 @@
+// ── The UI kit's props, as core actually reads them ───────────────────────
+//
+// React drops an unknown prop without a word, so a UI-kit component called with
+// the wrong one renders — just not what was written. Two of these have shipped
+// from this org already: `PageHeader subtitle` (Teams phase 11, the kit's
+// template) and `EmptyState title/message` (this module, phases 4 to 8 — every
+// empty panel was a blank box until the presence fix's browser walk).
+//
+// A DOM-less runner cannot see a blank box, so this reads the source instead:
+// it names the props core's components do NOT take and fails on any use of them.
+// It is a claim about core that must be re-read when core's kit changes —
+// written down rather than imported, because no core is in this process.
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src')
+
+/** Every .jsx/.js under src/. */
+function sources(dir = SRC) {
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) return sources(full)
+ return /\.(jsx?|mjs)$/.test(entry.name) ? [full] : []
+ })
+}
+
+// Core's `components/PageState.jsx` and `PageHeader.jsx`, read 2026-09-23 at the
+// pinned core (ci/core-ref.json).
+const REFUSED = {
+ // `EmptyState({ children })` — children only.
+ EmptyState: /]*\b(title|message|description|text)\s*=/,
+ // `PageHeader({ eyebrow, title, lead, center })` — there is no `subtitle`.
+ PageHeader: /]*\bsubtitle\s*=/,
+}
+
+test('no UI-kit component is handed a prop core does not read', () => {
+ const offences = []
+ for (const file of sources()) {
+ const text = fs.readFileSync(file, 'utf8')
+ for (const [component, pattern] of Object.entries(REFUSED)) {
+ if (pattern.test(text)) offences.push(`${path.relative(SRC, file)}: ${component}`)
+ }
+ }
+ assert.deepEqual(offences, [], 'use components/Empty.jsx for a titled empty state')
+})
diff --git a/routes.manifest.json b/routes.manifest.json
index d9eaa66..f3d3484 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -66,6 +66,11 @@
"path": "/api/v1/admin/rust/servers",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/rust/visibility",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/links",
@@ -175,6 +180,11 @@
"method": "PUT",
"path": "/api/v1/admin/rust/servers/:id",
"tier": "public"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/rust/visibility",
+ "tier": "public"
}
]
}
diff --git a/server/catalogue.js b/server/catalogue.js
index de3c6ba..c09b8df 100644
--- a/server/catalogue.js
+++ b/server/catalogue.js
@@ -82,11 +82,39 @@ const STAFF_KINDS = Object.freeze([
'perm.drift',
])
+/**
+ * The public kinds that say a NAMED player was on the server at a given moment.
+ *
+ * A subset of `PUBLIC_KINDS`, not a third list: these are public-page material
+ * whose audience an operator chooses (`model/visibility`), where the rest of
+ * `PUBLIC_KINDS` is public by construction. The org lead's rule, settled
+ * 2026-09-22: **nothing tells who is online by default** — the narrowest
+ * audience (staff) unless an operator widens it, and a count is never a name.
+ *
+ * `player.death` and `player.chat` are here, and that was decided rather than
+ * overlooked. They are the killfeed and the chat — the content a feed exists
+ * for — and each one says "this person was on at 12:03" as plainly as a connect
+ * frame does. `player.tally` is a per-minute flush that is only ever sent for a
+ * player who is playing, which makes it a roll call with extra steps.
+ *
+ * What is left in the public set once these are removed is the server's own
+ * story — a wipe, a start, a shutdown — which names nobody.
+ */
+const PRESENCE_KINDS = Object.freeze([
+ 'player.connected',
+ 'player.disconnected',
+ 'player.respawned',
+ 'player.death',
+ 'player.chat',
+ 'player.tally',
+])
+
/** Every kind protocol 3 defines. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)
const STAFF = new Set(STAFF_KINDS)
+const PRESENCE = new Set(PRESENCE_KINDS)
/**
* May a signed-out visitor see this kind?
@@ -103,15 +131,27 @@ function isKnown(kind) {
return PUBLIC.has(kind) || STAFF.has(kind)
}
+/** Does this kind name a player who was on the server at the time? */
+function isPresence(kind) {
+ return PRESENCE.has(kind)
+}
+
/**
* Narrows a list of requested kinds to the ones a viewer may have.
*
* Returning the allowlist itself when nothing was requested is what makes the
* public route safe by construction rather than by remembering to filter: there
* is no code path where "no filter" means "everything".
+ *
+ * `presence` defaults to `false` for the same reason `admin` does: a caller that
+ * forgets to say what the viewer may see gets the narrowest answer. The route
+ * resolves it from the operator's setting (`model/visibility`); nothing else
+ * should be passing `true`.
*/
-function kindsFor({ admin = false, requested = null } = {}) {
- const permitted = admin ? ALL_KINDS : PUBLIC_KINDS
+function kindsFor({ admin = false, presence = false, requested = null } = {}) {
+ const permitted = admin
+ ? ALL_KINDS
+ : PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k))
if (!requested || requested.length === 0) return [...permitted]
@@ -122,8 +162,10 @@ function kindsFor({ admin = false, requested = null } = {}) {
module.exports = {
PUBLIC_KINDS,
STAFF_KINDS,
+ PRESENCE_KINDS,
ALL_KINDS,
isPublic,
isKnown,
+ isPresence,
kindsFor,
}
diff --git a/server/core.js b/server/core.js
index d1862b5..f8ad635 100644
--- a/server/core.js
+++ b/server/core.js
@@ -86,6 +86,14 @@ module.exports = {
// that needs an identity needs to *read* one.
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
+ // One user by id (MODULE_API.md §2.3, 1.1.0). Here for the presence gate
+ // (`model/visibility`): `getUserFromRequest` decodes a token and nothing more,
+ // so the role in it is the role the account had when the token was minted. A
+ // moderator demoted this morning would keep reading who is online until their
+ // token expired. Re-reading the row is what makes a demotion — or a ban — take
+ // effect on the next request, the same promise core's admin tier makes.
+ users: { getById: (...args) => need().users.getById(...args) },
+
// Core's middleware, taken as values rather than wrapped: express stores the
// function reference at mount time, so a wrapper is what would end up in the
// stack. Routers are built inside `register()`, so `ctx` is set by then.
diff --git a/server/db/purge.sql b/server/db/purge.sql
index 21367ac..8882277 100644
--- a/server/db/purge.sql
+++ b/server/db/purge.sql
@@ -20,6 +20,7 @@
-- registrant owned what.
-- Phase 7b.
+DROP TABLE IF EXISTS rust_settings;
DROP TABLE IF EXISTS rust_config_writes;
-- Phase 7. Children before parents: every one of these carries a foreign key
diff --git a/server/db/schema.sql b/server/db/schema.sql
index c016df1..55ded29 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -668,3 +668,36 @@ CREATE TABLE IF NOT EXISTS rust_config_writes (
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
KEY idx_rust_config_writes_server (server_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+
+-- ── Who may see who is online (the presence fix, 2026-09-22) ──────────────
+--
+-- The org lead's rule: **nothing tells who is online by default.** The Online
+-- list, the killfeed, chat and every other frame that says a named player was on
+-- the server reach STAFF unless an operator deliberately widens them. A count is
+-- not a name and stays public.
+--
+-- Two places, because the decision has two shapes:
+--
+-- • `rust_settings` holds the FLEET default — one row per key. A key/value
+-- table rather than a column per setting, because phase 9's clan-roster
+-- audience is the next key and a table that grows a column per setting grows
+-- an ALTER per setting.
+-- • `rust_servers.presence_audience` is an optional PER-SERVER override. NULL
+-- means "inherit the fleet default", which is not the same as any audience —
+-- an operator who later narrows the fleet must narrow every server that never
+-- chose otherwise.
+--
+-- The stored value is a word (`staff` · `signed_in` · `public`) and an unknown
+-- word reads as `staff` (`model/visibility`): a typo in a row must narrow, never
+-- widen.
+CREATE TABLE IF NOT EXISTS rust_settings (
+ setting_key VARCHAR(64) NOT NULL PRIMARY KEY,
+ value VARCHAR(255) NOT NULL,
+ updated_by INT NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_rust_settings_user
+ FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;
diff --git a/server/model/events/events.model.js b/server/model/events/events.model.js
index 091b9b2..98f562f 100644
--- a/server/model/events/events.model.js
+++ b/server/model/events/events.model.js
@@ -51,8 +51,8 @@ function parseKinds(raw) {
* refused: naming it in an error would confirm the kind exists, which is a small
* thing to leak and a free one to avoid.
*/
-async function recent({ serverId, admin = false, kind = null, wipeId = null, limit }) {
- const kinds = catalogue.kindsFor({ admin, requested: parseKinds(kind) })
+async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
+ const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
// Every requested kind was refused. Answering with an empty list is right —
// the events they asked for are, as far as they are concerned, not there.
@@ -102,7 +102,7 @@ function shape(row) {
* counters, so the two can never disagree — which is the whole reason R12's
* "per-wipe detail plus all-time rollups" is one table and not two.
*/
-async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
+async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
const rows = await db.leaderboard({
serverId,
wipeId,
@@ -118,7 +118,11 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
npcKills: Number(r.npcKills) || 0,
structures: Number(r.structures) || 0,
playtimeSec: Number(r.playtimeSec) || 0,
- lastSeen: r.lastSeen || null,
+ // Withheld below the presence audience. A tally refreshes it every minute a
+ // player is on, so a `lastSeen` of forty seconds ago is the Online tab by
+ // another name. The ORDER still uses it as a tie-break — that says who was
+ // on more recently, never whether anybody is on now.
+ ...(presence ? { lastSeen: r.lastSeen || null } : {}),
}))
}
diff --git a/server/model/visibility/visibility.db.js b/server/model/visibility/visibility.db.js
new file mode 100644
index 0000000..4e0a5e7
--- /dev/null
+++ b/server/model/visibility/visibility.db.js
@@ -0,0 +1,55 @@
+// ── SQL for the visibility settings ───────────────────────────────────────
+//
+// Two stores for one decision: the fleet default in `rust_settings`, and an
+// optional per-server override on `rust_servers`. See `schema.sql` for why each
+// lives where it does.
+
+const core = require('../../core')
+
+const SETTINGS = 'rust_settings'
+const SERVERS = 'rust_servers'
+
+/** One setting's stored value, or `null` when nobody has ever set it. */
+async function getSetting(key) {
+ const rows = await core.query(`SELECT value FROM ${SETTINGS} WHERE setting_key = ?`, [key])
+ return rows[0] ? rows[0].value : null
+}
+
+async function setSetting(key, value, userId = null) {
+ await core.query(
+ `INSERT INTO ${SETTINGS} (setting_key, value, updated_by, updated_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
+ updated_at = CURRENT_TIMESTAMP`,
+ [key, value, userId],
+ )
+}
+
+/** One server's override, `null` for "inherit", or `undefined` when there is no such server. */
+async function getServerPresence(serverId) {
+ const rows = await core.query(`SELECT presence_audience AS presence FROM ${SERVERS} WHERE id = ?`, [serverId])
+ return rows[0] ? rows[0].presence : undefined
+}
+
+/** Every configured server with its override, in the operator's own order. */
+async function listServerPresence() {
+ return core.query(
+ `SELECT id, name, enabled, presence_audience AS presence
+ FROM ${SERVERS}
+ ORDER BY sort_order ASC, id ASC`,
+ )
+}
+
+/**
+ * Sets or clears (`null`) one server's override.
+ *
+ * Returns nothing, deliberately. `affectedRows` would look like a way to tell
+ * "no such server" from success, and it is not one: without `foundRows` an
+ * UPDATE writing the value already there reports 0, and whether core's pool sets
+ * that flag is core's business. The model checks existence with a read first.
+ */
+async function setServerPresence(serverId, value) {
+ await core.query(`UPDATE ${SERVERS} SET presence_audience = ? WHERE id = ?`, [value, serverId])
+}
+
+module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence }
diff --git a/server/model/visibility/visibility.model.js b/server/model/visibility/visibility.model.js
new file mode 100644
index 0000000..fc99a64
--- /dev/null
+++ b/server/model/visibility/visibility.model.js
@@ -0,0 +1,207 @@
+// ── Who may see who is online ─────────────────────────────────────────────
+//
+// The org lead's rule, settled 2026-09-22: **nothing tells who is online by
+// default.** It is always the lowest blast radius — staff — unless an operator
+// deliberately widens it, and a COUNT of players is fine where a list of names
+// is not.
+//
+// "Who is online" is wider than the Online tab. Every frame that says a named
+// player was on the server at a given moment says it: a connect, a respawn, a
+// death, a chat line, a gather tally (`catalogue.PRESENCE_KINDS`), and a
+// leaderboard row's `lastSeen`, which a tally refreshes every minute while
+// somebody plays. All of them sit behind this one setting.
+//
+// ── The audiences ─────────────────────────────────────────────────────────
+//
+// staff an admin or a moderator — the two roles every Team surface in
+// core also means by "staff"
+// signed_in any active website account
+// public anybody, signed in or not
+//
+// Ordered, each rung implying the ones below it. The names line up with phase
+// 14's map-layer switches (public / players / admin) so that one layer can take
+// this over rather than sit beside it.
+//
+// ── Two fallbacks, deliberately asymmetric ────────────────────────────────
+//
+// An unrecognised VIEWER reads as the bottom rung and an unrecognised
+// REQUIREMENT reads as the top one, so a value nobody expected always loses.
+// One shared fallback cannot do that: whichever way it points, it fails open on
+// one side. module-uo's shard visibility learned this the hard way; the rule is
+// copied here rather than rediscovered.
+
+const core = require('../../core')
+
+const db = require('./visibility.db')
+
+const log = core.logger('visibility')
+
+const AUDIENCES = Object.freeze(['public', 'signed_in', 'staff'])
+const RANK = new Map(AUDIENCES.map((a, i) => [a, i]))
+
+/** The narrowest rung, and the default wherever nothing has been chosen. */
+const DEFAULT_PRESENCE = 'staff'
+
+/** The `rust_settings` key the fleet default lives under. */
+const PRESENCE_KEY = 'presence.audience'
+
+const isAudience = (value) => RANK.has(value)
+
+const viewerRank = (level) => RANK.get(level) ?? 0
+const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
+
+/** Does a viewer at `viewer` satisfy a requirement of `required`? */
+const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
+
+/**
+ * The viewer's rung, re-read from the database.
+ *
+ * `getUserFromRequest` decodes a token and nothing more — the role in it is the
+ * role the account had when it signed in. For a gate on who may see who is
+ * online, that is not good enough: a moderator demoted this morning would keep
+ * the roll call until their token expired, and a banned account would keep
+ * reading it too. So the token only says WHO; the row says what they are now.
+ *
+ * Any failure resolves to `public` — the bottom rung — because an unanswerable
+ * question about somebody's standing must grant nothing.
+ */
+async function viewerLevel(req) {
+ try {
+ const claimed = req.user || core.auth.getUserFromRequest(req)
+ if (!claimed || claimed.id == null) return 'public'
+
+ const user = await core.users.getById(claimed.id)
+ if (!user) return 'public'
+ if (user.status && user.status !== 'active') return 'public'
+
+ if (user.role === 'admin' || user.role === 'moderator') return 'staff'
+ return 'signed_in'
+ } catch (err) {
+ log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
+ return 'public'
+ }
+}
+
+/** A stored value as an audience, narrowing anything this build does not recognise. */
+function normalise(value) {
+ return isAudience(value) ? value : DEFAULT_PRESENCE
+}
+
+/** The fleet default. */
+async function fleetPresence() {
+ const stored = await db.getSetting(PRESENCE_KEY)
+ return stored == null ? DEFAULT_PRESENCE : normalise(stored)
+}
+
+/**
+ * The audience that applies to one server: its override if it has one, the
+ * fleet default otherwise.
+ *
+ * A server that does not exist gets the fleet default, which is the right answer
+ * for the routes that call this: they answer an empty list for an unknown id,
+ * and an empty list is empty at every rung.
+ */
+async function presenceFor(serverId) {
+ const override = await db.getServerPresence(serverId)
+ if (override != null) return normalise(override)
+ return fleetPresence()
+}
+
+/**
+ * Everything a public route needs in one call: may this viewer see who is on
+ * this server?
+ *
+ * Throws nothing. A setting that cannot be read resolves to "no" — the routes
+ * that ask would otherwise have to choose between a 500 and publishing names.
+ */
+async function canSeePresence(req, serverId) {
+ try {
+ const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
+ return { visible: meets(level, required), level, required }
+ } catch (err) {
+ log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
+ return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
+ }
+}
+
+/** The admin screen's read: the fleet default and every server beside it. */
+async function describe() {
+ const [fleet, servers] = await Promise.all([fleetPresence(), db.listServerPresence()])
+ return {
+ audiences: [...AUDIENCES],
+ presence: {
+ fleet,
+ servers: servers.map((s) => {
+ const override = s.presence == null ? null : normalise(s.presence)
+ return {
+ id: s.id,
+ name: s.name,
+ enabled: Boolean(s.enabled),
+ override,
+ effective: override || fleet,
+ }
+ }),
+ },
+ }
+}
+
+/**
+ * The admin screen's write.
+ *
+ * `fleet` is optional; `servers` maps an id to an audience, or to `null` to
+ * clear its override. Validated whole before anything is written, so a request
+ * naming one unknown server changes nothing rather than half of what it asked.
+ *
+ * Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
+ * sentence the page can show.
+ */
+async function update({ fleet, servers } = {}, actor = null) {
+ if (fleet !== undefined && !isAudience(fleet)) {
+ return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
+ }
+
+ const changes = Object.entries(servers || {})
+ for (const [id, value] of changes) {
+ if (value !== null && !isAudience(value)) {
+ return { ok: false, status: 400, message: `"${value}" is not an audience for server ${id}.` }
+ }
+ // eslint-disable-next-line no-await-in-loop
+ if ((await db.getServerPresence(id)) === undefined) {
+ return { ok: false, status: 404, message: `There is no server called ${id}.` }
+ }
+ }
+
+ const userId = actor && actor.id != null ? actor.id : null
+
+ if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
+ for (const [id, value] of changes) {
+ // eslint-disable-next-line no-await-in-loop
+ await db.setServerPresence(id, value)
+ }
+
+ // What was written, for the controller's audit row. Recorded there rather than
+ // here because the activity log takes the REQUEST (who, from where), and a
+ // model that took a request would be a model that could only be called by one.
+ return {
+ ok: true,
+ changed: {
+ ...(fleet !== undefined ? { fleet } : {}),
+ servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
+ },
+ }
+}
+
+module.exports = {
+ AUDIENCES,
+ DEFAULT_PRESENCE,
+ PRESENCE_KEY,
+ isAudience,
+ meets,
+ normalise,
+ viewerLevel,
+ fleetPresence,
+ presenceFor,
+ canSeePresence,
+ describe,
+ update,
+}
diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js
index a74de9f..6430e2d 100644
--- a/server/router/admin/rust.router.js
+++ b/server/router/admin/rust.router.js
@@ -37,6 +37,11 @@ adminRustRouter.use('/permissions', require('./permissions.router'))
// and this one edits the game host's own plugin settings.
adminRustRouter.use('/config', require('./config.router'))
+// Who may see who is online, under `/rust/visibility`. The org lead's rule is
+// that nothing names who is online by default; this is where an operator
+// deliberately widens it, fleet-wide or for one server.
+adminRustRouter.use('/visibility', require('./visibility.router'))
+
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']
diff --git a/server/router/admin/visibility.controller.js b/server/router/admin/visibility.controller.js
new file mode 100644
index 0000000..1c4b7b2
--- /dev/null
+++ b/server/router/admin/visibility.controller.js
@@ -0,0 +1,39 @@
+// ── Admin · Rust · Visibility — the handlers ──────────────────────────────
+
+const core = require('../../core')
+
+const visibility = require('../../model/visibility/visibility.model')
+
+const log = core.logger('visibility')
+
+async function read(req, res) {
+ try {
+ res.json(await visibility.describe())
+ } catch (err) {
+ log.error('failed to read visibility settings', { error: err.message })
+ res.status(500).json({ message: 'Failed to read the visibility settings' })
+ }
+}
+
+async function update(req, res) {
+ try {
+ const { fleet, servers } = req.body || {}
+ const result = await visibility.update({ fleet, servers }, req.user)
+ if (!result.ok) {
+ res.status(result.status || 400).json({ message: result.message })
+ return
+ }
+
+ // One row per save, naming everything it changed. Widening who may see the
+ // roll call is exactly the kind of change somebody later needs to trace to a
+ // person and a time.
+ await core.activity.log({ req, action: 'rust.visibility.save', detail: result.changed })
+
+ res.json(await visibility.describe())
+ } catch (err) {
+ log.error('failed to save visibility settings', { error: err.message })
+ res.status(500).json({ message: 'Failed to save the visibility settings' })
+ }
+}
+
+module.exports = { read, update }
diff --git a/server/router/admin/visibility.router.js b/server/router/admin/visibility.router.js
new file mode 100644
index 0000000..03892bb
--- /dev/null
+++ b/server/router/admin/visibility.router.js
@@ -0,0 +1,51 @@
+// ── Admin · Rust · Visibility ─────────────────────────────────────────────
+//
+// Mounted under the admin tier's `/rust` prefix, so every path here is
+// `/api/v1/admin/rust/visibility`. Who may see what the servers say about the
+// people on them — a fourth subject beside the bridge, the permissions and the
+// mod configuration.
+//
+// **Every route is `requireRole('admin')`.** The tier's own gate admits editors
+// and moderators, and a moderator widening the roll call to the public is the
+// decision the org lead settled should be deliberate. Reading is gated the same
+// as writing: the screen is one form, and a view of the settings without the
+// power to change them is not something anybody has asked for.
+
+const core = require('../../core')
+
+const express = core.express
+const visibility = require('./visibility.controller')
+const { requireRole, validate } = core.middleware
+const { body } = core.validator
+
+const visibilityRouter = express.Router()
+
+const AUDIENCES = ['staff', 'signed_in', 'public']
+
+visibilityRouter.get(
+ '/',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'Who may see who is online'
+ // #swagger.description = 'The fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.'
+ /* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
+ requireRole('admin'),
+ visibility.read,
+)
+
+visibilityRouter.put(
+ '/',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'Change who may see who is online'
+ // #swagger.description = 'Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.'
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
+ /* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
+ /* #swagger.responses[400] = { description: 'An audience that does not exist' } */
+ /* #swagger.responses[404] = { description: 'A server that does not exist' } */
+ requireRole('admin'),
+ body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
+ body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
+ validate,
+ visibility.update,
+)
+
+module.exports = visibilityRouter
diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js
index b208378..ce05d14 100644
--- a/server/router/public/rust.controller.js
+++ b/server/router/public/rust.controller.js
@@ -13,9 +13,24 @@ const core = require('../../core')
const events = require('../../model/events/events.model')
const servers = require('../../model/servers/servers.model')
+const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('public')
+/**
+ * Marks a response as depending on who asked.
+ *
+ * Three routes below answer differently for a moderator and for a stranger, and
+ * a shared cache in front of the site that stored the moderator's answer would
+ * hand the roll call to the next anonymous visitor. `private` keeps it out of
+ * every cache but the viewer's own; `Vary` says why, for any cache that reads it.
+ */
+function perViewer(res) {
+ res.set('Cache-Control', 'private, no-store')
+ res.vary('Cookie')
+ res.vary('Authorization')
+}
+
async function listServers(req, res) {
try {
res.json({ servers: await servers.listPublic() })
@@ -56,16 +71,26 @@ async function getServer(req, res) {
* handler.** `events.recent` takes the viewer explicitly and defaults to the
* public allowlist, so the way to leak an IP address from here is to add an
* argument rather than to forget one.
+ *
+ * `presence` is resolved per request from the operator's setting. Below it, the
+ * feed carries only what names nobody — a wipe, a start, a shutdown — and says
+ * so with `presenceHidden`, so a page can explain a quiet feed instead of
+ * implying a quiet server.
*/
async function listEvents(req, res) {
try {
+ const presence = await visibility.canSeePresence(req, req.params.id)
+ perViewer(res)
res.json({
events: await events.recent({
serverId: req.params.id,
+ presence: presence.visible,
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,
}),
+ presenceHidden: !presence.visible,
+ presenceAudience: presence.required,
})
} catch (err) {
log.error('failed to read events', { server: req.params.id, error: err.message })
@@ -75,12 +100,15 @@ async function listEvents(req, res) {
async function listLeaderboard(req, res) {
try {
+ const presence = await visibility.canSeePresence(req, req.params.id)
+ perViewer(res)
res.json({
leaderboard: await events.leaderboard({
serverId: req.params.id,
wipeId: req.query.wipe || null,
sort: req.query.sort,
limit: req.query.limit,
+ presence: presence.visible,
}),
})
} catch (err) {
@@ -98,9 +126,34 @@ async function listWipes(req, res) {
}
}
+/**
+ * Who is on the server right now — or, below the operator's audience, how many.
+ *
+ * The count stays public: it is already on the server list and in the footer,
+ * and a number names nobody. The names do not, by default (the org lead's rule,
+ * `model/visibility`). A hidden answer is still a 200 with the same shape — an
+ * empty `players` array — plus `hidden` and `count`, so a client that predates
+ * the flag renders an empty list rather than breaking, and a current one can say
+ * "12 online" instead of "nobody".
+ */
async function listOnline(req, res) {
try {
- res.json({ players: await events.online(req.params.id) })
+ const presence = await visibility.canSeePresence(req, req.params.id)
+ perViewer(res)
+
+ if (!presence.visible) {
+ const server = await servers.getPublic(req.params.id)
+ res.json({
+ players: [],
+ hidden: true,
+ count: server ? server.players : 0,
+ audience: presence.required,
+ })
+ return
+ }
+
+ const players = await events.online(req.params.id)
+ res.json({ players, hidden: false, count: players.length, audience: presence.required })
} catch (err) {
log.error('failed to read presence', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read who is online' })
diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js
index 5a98c3c..60116c2 100644
--- a/server/router/public/rust.router.js
+++ b/server/router/public/rust.router.js
@@ -68,7 +68,7 @@ rustRouter.get(
'/servers/:id/events',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Recent events on one Rust server'
- // #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here.'
+ // #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
@@ -82,7 +82,7 @@ rustRouter.get(
'/servers/:id/leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The leaderboard for one Rust server'
- // #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it.'
+ // #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
@@ -107,9 +107,9 @@ rustRouter.get(
'/servers/:id/online',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Who is on one Rust server right now'
- // #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one.'
+ // #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one. **Nothing names who is online by default**: below the operator’s presence audience (staff unless widened) the names are withheld and only `count` is answered.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
- /* #swagger.responses[200] = { description: 'Who is online' } */
+ /* #swagger.responses[200] = { description: 'Who is online — or, below the operator’s presence audience, only how many', content: { "application/json": { schema: { $ref: "#/components/schemas/RustOnline" } } } } */
siteMode,
servers.listOnline,
)
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index fd20563..a87da18 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -478,6 +478,77 @@ module.exports = {
},
},
},
+ RustOnline: {
+ type: 'object',
+ description: 'Who is on one server (GET /public/rust/servers/{id}/online). Below the operator’s presence audience the names are withheld and only the count is answered — nothing names who is online by default.',
+ properties: {
+ players: {
+ type: 'array',
+ description: 'Empty whenever `hidden` is true.',
+ items: {
+ type: 'object',
+ properties: {
+ steamId: { type: 'string', example: '76561198000000000' },
+ name: { type: 'string', nullable: true, example: 'Wanderer' },
+ sleeping: { type: 'boolean', example: false },
+ connectedAt: { type: 'string', nullable: true },
+ },
+ },
+ },
+ hidden: { type: 'boolean', description: 'Were the names withheld from this viewer?', example: true },
+ count: { type: 'integer', description: 'How many are online. Public at every audience.', example: 12 },
+ audience: { $ref: '#/components/schemas/RustAudience' },
+ },
+ },
+ RustAudience: {
+ type: 'string',
+ enum: ['staff', 'signed_in', 'public'],
+ description: 'Who may see something: admins and moderators, any signed-in account, or anybody. Ordered — each includes the ones before it.',
+ example: 'staff',
+ },
+ RustVisibility: {
+ type: 'object',
+ description: 'Who may see who is online: the fleet default and each server’s optional override (GET /admin/rust/visibility).',
+ properties: {
+ audiences: { type: 'array', items: { $ref: '#/components/schemas/RustAudience' } },
+ presence: {
+ type: 'object',
+ properties: {
+ fleet: { $ref: '#/components/schemas/RustAudience' },
+ servers: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', example: 'main' },
+ name: { type: 'string', example: 'Main · Vanilla' },
+ enabled: { type: 'boolean', example: true },
+ override: {
+ type: 'string',
+ nullable: true,
+ enum: ['staff', 'signed_in', 'public', null],
+ description: 'This server’s own choice, or null to follow the fleet default.',
+ },
+ effective: { $ref: '#/components/schemas/RustAudience' },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ RustVisibilityUpdate: {
+ type: 'object',
+ description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
+ properties: {
+ fleet: { $ref: '#/components/schemas/RustAudience' },
+ servers: {
+ type: 'object',
+ additionalProperties: { type: 'string', nullable: true, enum: ['staff', 'signed_in', 'public', null] },
+ example: { main: 'public', pvp: null },
+ },
+ },
+ },
RustSidecarProbe: {
type: 'object',
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
diff --git a/server/test/_fakes.js b/server/test/_fakes.js
index 4d3a1a8..31ef317 100644
--- a/server/test/_fakes.js
+++ b/server/test/_fakes.js
@@ -53,6 +53,9 @@ function fakeCtx(overrides = {}) {
return log
},
auth: { getUserFromRequest: spy(null) },
+ // One user by id. Null by default — an anonymous suite resolves nobody —
+ // and a test that needs a viewer installs its own.
+ users: { getById: spy(Promise.resolve(null)) },
// The engagement seam (§2.3). One method, recording, because that is the
// whole of what a module may do with it: fire a declared event and stop.
// Core's own emit is fire-and-forget and returns nothing, so this does too —
diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js
index d97836d..b08995a 100644
--- a/server/test/catalogue.test.js
+++ b/server/test/catalogue.test.js
@@ -51,7 +51,12 @@ test('a viewer with no kinds asked for gets the allowlist, never everything', ()
const asPublic = catalogue.kindsFor({})
const asAdmin = catalogue.kindsFor({ admin: true })
- assert.deepEqual(asPublic, [...catalogue.PUBLIC_KINDS])
+ // The public view with nothing said about presence is the kinds that name
+ // nobody — a wipe, a start, a shutdown.
+ assert.deepEqual(
+ asPublic,
+ catalogue.PUBLIC_KINDS.filter((k) => !catalogue.PRESENCE_KINDS.includes(k)),
+ )
assert.equal(asAdmin.length, catalogue.ALL_KINDS.length)
// The property that makes the route safe by construction: there is no argument
@@ -61,10 +66,13 @@ test('a viewer with no kinds asked for gets the allowlist, never everything', ()
})
test('a kind a viewer may not see is dropped, not refused', () => {
- const asked = catalogue.kindsFor({ requested: ['player.death', 'player.banned'] })
+ const asked = catalogue.kindsFor({ presence: true, requested: ['player.death', 'player.banned'] })
assert.deepEqual(asked, ['player.death'])
+ // Without the presence audience a death is dropped too.
+ assert.deepEqual(catalogue.kindsFor({ requested: ['player.death', 'server.wipe'] }), ['server.wipe'])
+
// Asking for only forbidden kinds answers with nothing to select, which the
// model turns into an empty list — the events are, as far as this viewer is
// concerned, not there.
@@ -116,3 +124,26 @@ test('the classification covers exactly the kinds protocol 4 defines', () => {
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
})
+
+test('every kind that names a player who was on is behind the presence setting', () => {
+ // The org lead's rule (2026-09-22): nothing tells who is online by default.
+ // Each of these says a named player was on the server at a given moment.
+ for (const kind of [
+ 'player.connected',
+ 'player.disconnected',
+ 'player.respawned',
+ 'player.death',
+ 'player.chat',
+ 'player.tally',
+ ]) {
+ assert.ok(catalogue.isPresence(kind), `${kind} must be gated as presence`)
+ assert.ok(!catalogue.kindsFor({}).includes(kind), `${kind} must not reach a default public view`)
+ assert.ok(catalogue.kindsFor({ presence: true }).includes(kind))
+ }
+
+ // A presence kind is a subset of the public ones, never a staff kind widened.
+ for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
+
+ // And what is left names nobody.
+ assert.deepEqual(catalogue.kindsFor({}).sort(), ['server.initialized', 'server.shutdown', 'server.wipe'])
+})
diff --git a/server/test/events.test.js b/server/test/events.test.js
index 4c2ccc8..0c46d61 100644
--- a/server/test/events.test.js
+++ b/server/test/events.test.js
@@ -60,7 +60,14 @@ test('a reader who does not say who they are gets the public view', async () =>
await model.recent({ serverId: 'main' })
assert.ok(!asked.kinds.includes('player.banned'), 'no IP-carrying kind by default')
- assert.ok(asked.kinds.includes('player.death'))
+ // Nor anything naming a player who was on — the org lead's rule, and a caller
+ // that forgets to say what the viewer may see gets the narrowest answer.
+ assert.ok(!asked.kinds.includes('player.death'), 'no presence kind by default')
+ assert.ok(asked.kinds.includes('server.wipe'))
+
+ await model.recent({ serverId: 'main', presence: true })
+ assert.ok(asked.kinds.includes('player.death'), 'a viewer inside the presence audience gets the killfeed')
+ assert.ok(!asked.kinds.includes('player.banned'), 'presence never widens to staff kinds')
await model.recent({ serverId: 'main', admin: true })
assert.ok(asked.kinds.includes('player.banned'), 'an admin who says so gets them')
@@ -142,3 +149,24 @@ test('the leaderboard answers numbers, never nulls', async () => {
db.leaderboard = original
}
})
+
+test('the leaderboard withholds lastSeen unless the viewer may see who is online', async () => {
+ withCore()
+
+ const db = require('../model/events/events.db')
+ const model = require('../model/events/events.model')
+ const original = db.leaderboard
+
+ db.leaderboard = async () => [{ steamId: '7656', name: 'A', kills: 3, lastSeen: '2026-09-22T10:00:00Z' }]
+
+ try {
+ const hidden = await model.leaderboard({ serverId: 'main' })
+ assert.equal('lastSeen' in hidden[0], false, 'absent, not null — null would read as "never seen"')
+ assert.equal(hidden[0].kills, 3)
+
+ const shown = await model.leaderboard({ serverId: 'main', presence: true })
+ assert.equal(shown[0].lastSeen, '2026-09-22T10:00:00Z')
+ } finally {
+ db.leaderboard = original
+ }
+})
diff --git a/server/test/visibility.test.js b/server/test/visibility.test.js
new file mode 100644
index 0000000..318b4a2
--- /dev/null
+++ b/server/test/visibility.test.js
@@ -0,0 +1,262 @@
+// ── Who may see who is online ─────────────────────────────────────────────
+//
+// The org lead's rule (2026-09-22): nothing tells who is online by default. The
+// suite holds the four properties that make that rule true rather than merely
+// intended:
+//
+// • an install nobody has configured answers STAFF;
+// • the viewer's standing comes from the ROW, not the token — a demotion or a
+// ban takes effect on the next request;
+// • anything unrecognised or unanswerable narrows, never widens;
+// • the public routes answer the count and withhold the names.
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx, spy } = require('./_fakes')
+
+/**
+ * The model with a stubbed db and a chosen viewer.
+ *
+ * `claimed` is what the token says; `row` is what the users table says now.
+ */
+function setup({ fleet = null, overrides = {}, claimed = null, row = null, usersThrow = false } = {}) {
+ require('../core')._reset()
+ require('../core').init(
+ fakeCtx({
+ auth: { getUserFromRequest: () => claimed },
+ users: {
+ getById: async () => {
+ if (usersThrow) throw new Error('pool exhausted')
+ return row
+ },
+ },
+ }),
+ )
+
+ const db = require('../model/visibility/visibility.db')
+ const model = require('../model/visibility/visibility.model')
+
+ const written = { settings: [], servers: [] }
+ const originals = { ...db }
+ db.getSetting = async () => fleet
+ db.setSetting = async (key, value, userId) => written.settings.push({ key, value, userId })
+ db.getServerPresence = async (id) => (id in overrides ? overrides[id] : undefined)
+ db.listServerPresence = async () =>
+ Object.entries(overrides).map(([id, presence]) => ({ id, name: id.toUpperCase(), enabled: 1, presence }))
+ db.setServerPresence = async (id, value) => written.servers.push({ id, value })
+
+ return { model, written, restore: () => Object.assign(db, originals) }
+}
+
+test('an install nobody has configured shows the roll call to staff and nobody else', async () => {
+ const { model, restore } = setup({ overrides: { main: null } })
+ try {
+ assert.equal(await model.fleetPresence(), 'staff')
+ assert.equal(await model.presenceFor('main'), 'staff')
+ assert.equal((await model.canSeePresence({}, 'main')).visible, false, 'anonymous')
+ } finally {
+ restore()
+ }
+})
+
+test('the standing comes from the row, not the token', async () => {
+ // The token says moderator; the row says they were demoted this morning.
+ const demoted = setup({ claimed: { id: 4, role: 'moderator' }, row: { id: 4, role: 'player', status: 'active' } })
+ try {
+ assert.equal(await demoted.model.viewerLevel({}), 'signed_in')
+ } finally {
+ demoted.restore()
+ }
+
+ // The token says admin; the account has been banned since.
+ const banned = setup({ claimed: { id: 4, role: 'admin' }, row: { id: 4, role: 'admin', status: 'banned' } })
+ try {
+ assert.equal(await banned.model.viewerLevel({}), 'public')
+ } finally {
+ banned.restore()
+ }
+
+ const moderator = setup({ claimed: { id: 5 }, row: { id: 5, role: 'moderator', status: 'active' } })
+ try {
+ assert.equal(await moderator.model.viewerLevel({}), 'staff')
+ } finally {
+ moderator.restore()
+ }
+})
+
+test('a viewer who cannot be resolved is anonymous', async () => {
+ const gone = setup({ claimed: { id: 9 }, row: null })
+ try {
+ assert.equal(await gone.model.viewerLevel({}), 'public')
+ } finally {
+ gone.restore()
+ }
+
+ const failing = setup({ claimed: { id: 9 }, usersThrow: true })
+ try {
+ assert.equal(await failing.model.viewerLevel({}), 'public')
+ } finally {
+ failing.restore()
+ }
+})
+
+test('a stored value this build does not recognise narrows to staff', async () => {
+ const { model, restore } = setup({ fleet: 'everyone', overrides: { main: 'PUBLIC', pvp: null } })
+ try {
+ assert.equal(await model.fleetPresence(), 'staff')
+ assert.equal(await model.presenceFor('main'), 'staff', 'a mis-cased word is not "public"')
+ assert.equal(await model.presenceFor('pvp'), 'staff', 'inherits the (narrowed) fleet default')
+ } finally {
+ restore()
+ }
+})
+
+test('a server override wins over the fleet, and null inherits it', async () => {
+ const { model, restore } = setup({
+ fleet: 'signed_in',
+ overrides: { main: 'public', pvp: 'staff', creative: null },
+ claimed: { id: 4 },
+ row: { id: 4, role: 'player', status: 'active' },
+ })
+ try {
+ assert.equal(await model.presenceFor('main'), 'public')
+ assert.equal(await model.presenceFor('pvp'), 'staff')
+ assert.equal(await model.presenceFor('creative'), 'signed_in')
+
+ // A signed-in player sees main and creative, not pvp.
+ assert.equal((await model.canSeePresence({}, 'main')).visible, true)
+ assert.equal((await model.canSeePresence({}, 'creative')).visible, true)
+ assert.equal((await model.canSeePresence({}, 'pvp')).visible, false)
+
+ const described = await model.describe()
+ const byId = Object.fromEntries(described.presence.servers.map((s) => [s.id, s]))
+ assert.equal(byId.creative.override, null)
+ assert.equal(byId.creative.effective, 'signed_in')
+ assert.equal(byId.pvp.effective, 'staff')
+ } finally {
+ restore()
+ }
+})
+
+test('an update naming an unknown audience or server writes nothing at all', async () => {
+ const { model, written, restore } = setup({ overrides: { main: null } })
+ try {
+ const badAudience = await model.update({ fleet: 'public', servers: { main: 'everyone' } })
+ assert.equal(badAudience.ok, false)
+ assert.equal(badAudience.status, 400)
+
+ const badServer = await model.update({ fleet: 'public', servers: { main: 'public', nope: 'public' } })
+ assert.equal(badServer.ok, false)
+ assert.equal(badServer.status, 404)
+ assert.match(badServer.message, /nope/)
+
+ assert.deepEqual(written, { settings: [], servers: [] }, 'validated whole before anything was written')
+
+ const ok = await model.update({ fleet: 'signed_in', servers: { main: null } }, { id: 1 })
+ assert.equal(ok.ok, true)
+ assert.deepEqual(written.settings, [{ key: 'presence.audience', value: 'signed_in', userId: 1 }])
+ assert.deepEqual(written.servers, [{ id: 'main', value: null }])
+ assert.deepEqual(ok.changed, { fleet: 'signed_in', servers: { main: 'inherit' } })
+ } finally {
+ restore()
+ }
+})
+
+// ── The public routes ─────────────────────────────────────────────────────
+
+/** A response double recording what a handler answered. */
+function fakeRes() {
+ const res = {
+ statusCode: 200,
+ headers: {},
+ varied: [],
+ body: undefined,
+ status(code) { this.statusCode = code; return this },
+ json(body) { this.body = body; return this },
+ set(name, value) { this.headers[name.toLowerCase()] = value; return this },
+ vary(name) { this.varied.push(name); return this },
+ }
+ return res
+}
+
+function withPresence(visible) {
+ const visibility = require('../model/visibility/visibility.model')
+ const events = require('../model/events/events.model')
+ const servers = require('../model/servers/servers.model')
+ const originals = {
+ canSeePresence: visibility.canSeePresence,
+ online: events.online,
+ getPublic: servers.getPublic,
+ }
+ visibility.canSeePresence = async () => ({ visible, level: visible ? 'staff' : 'public', required: 'staff' })
+ events.online = spy(Promise.resolve([{ steamId: '7656', name: 'Wanderer', sleeping: false, connectedAt: null }]))
+ servers.getPublic = async () => ({ id: 'main', players: 12 })
+ return {
+ events,
+ restore: () => {
+ visibility.canSeePresence = originals.canSeePresence
+ events.online = originals.online
+ servers.getPublic = originals.getPublic
+ },
+ }
+}
+
+test('below the audience, the Online list answers the count and never reads the names', async () => {
+ require('../core')._reset()
+ require('../core').init(fakeCtx())
+ const { events, restore } = withPresence(false)
+ try {
+ const controller = require('../router/public/rust.controller')
+ const res = fakeRes()
+ await controller.listOnline({ params: { id: 'main' } }, res)
+
+ assert.deepEqual(res.body, { players: [], hidden: true, count: 12, audience: 'staff' })
+ assert.equal(events.online.calls.length, 0, 'the names are not even read')
+ assert.equal(res.headers['cache-control'], 'private, no-store', 'a per-viewer answer must not be shared by a cache')
+ } finally {
+ restore()
+ }
+})
+
+test('inside the audience, the Online list names the players', async () => {
+ require('../core')._reset()
+ require('../core').init(fakeCtx())
+ const { restore } = withPresence(true)
+ try {
+ const controller = require('../router/public/rust.controller')
+ const res = fakeRes()
+ await controller.listOnline({ params: { id: 'main' } }, res)
+
+ assert.equal(res.body.hidden, false)
+ assert.equal(res.body.players[0].name, 'Wanderer')
+ } finally {
+ restore()
+ }
+})
+
+test('below the audience, the feed says it withheld the players rather than implying a quiet server', async () => {
+ require('../core')._reset()
+ require('../core').init(fakeCtx())
+ const { restore } = withPresence(false)
+ const events = require('../model/events/events.model')
+ const original = events.recent
+ let asked = null
+ events.recent = async (args) => {
+ asked = args
+ return []
+ }
+ try {
+ const controller = require('../router/public/rust.controller')
+ const res = fakeRes()
+ await controller.listEvents({ params: { id: 'main' }, query: {} }, res)
+
+ assert.equal(asked.presence, false)
+ assert.equal(asked.admin, undefined, 'the public route never passes admin')
+ assert.equal(res.body.presenceHidden, true)
+ assert.equal(res.body.presenceAudience, 'staff')
+ } finally {
+ events.recent = original
+ restore()
+ }
+})
diff --git a/swagger-fragment.json b/swagger-fragment.json
index a933c8d..0a3858f 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -683,6 +683,68 @@
}
}
},
+ "/api/v1/admin/rust/visibility": {
+ "get": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "Who may see who is online",
+ "description": "The fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.",
+ "responses": {
+ "200": {
+ "description": "The fleet default and each server",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustVisibility"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "Change who may see who is online",
+ "description": "Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.",
+ "responses": {
+ "200": {
+ "description": "Saved; answers the new state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustVisibility"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "An audience that does not exist"
+ },
+ "404": {
+ "description": "A server that does not exist"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustVisibilityUpdate"
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/users/{id}/rust/links": {
"get": {
"tags": [
@@ -1245,7 +1307,7 @@
"Public · Rust"
],
"summary": "Recent events on one Rust server",
- "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here.",
+ "description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.",
"parameters": [
{
"name": "id",
@@ -1300,7 +1362,7 @@
"Public · Rust"
],
"summary": "The leaderboard for one Rust server",
- "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it.",
+ "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.",
"parameters": [
{
"name": "id",
@@ -1355,7 +1417,7 @@
"Public · Rust"
],
"summary": "Who is on one Rust server right now",
- "description": "Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one.",
+ "description": "Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one. **Nothing names who is online by default**: below the operator’s presence audience (staff unless widened) the names are withheld and only `count` is answered.",
"parameters": [
{
"name": "id",
@@ -1369,7 +1431,14 @@
],
"responses": {
"200": {
- "description": "Who is online"
+ "description": "Who is online — or, below the operator’s presence audience, only how many",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustOnline"
+ }
+ }
+ }
},
"500": {
"description": "Internal Server Error"
@@ -3894,6 +3963,374 @@
}
}
},
+ "RustOnline": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Who is on one server (GET /public/rust/servers/{id}/online). Below the operator’s presence audience the names are withheld and only the count is answered — nothing names who is online by default."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "players": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "Empty whenever `hidden` is true."
+ },
+ "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"
+ }
+ }
+ },
+ "sleeping": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "connectedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "hidden": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Were the names withheld from this viewer?"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "count": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "How many are online. Public at every audience."
+ },
+ "example": {
+ "type": "number",
+ "example": 12
+ }
+ }
+ },
+ "audience": {
+ "$ref": "#/components/schemas/RustAudience"
+ }
+ }
+ }
+ }
+ },
+ "RustAudience": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "staff",
+ "signed_in",
+ "public"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "Who may see something: admins and moderators, any signed-in account, or anybody. Ordered — each includes the ones before it."
+ },
+ "example": {
+ "type": "string",
+ "example": "staff"
+ }
+ }
+ },
+ "RustVisibility": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Who may see who is online: the fleet default and each server’s optional override (GET /admin/rust/visibility)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "audiences": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustAudience"
+ }
+ }
+ },
+ "presence": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "fleet": {
+ "$ref": "#/components/schemas/RustAudience"
+ },
+ "servers": {
+ "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": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Main · Vanilla"
+ }
+ }
+ },
+ "enabled": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "override": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "staff",
+ "signed_in",
+ "public",
+ null
+ ],
+ "items": {}
+ },
+ "description": {
+ "type": "string",
+ "example": "This server’s own choice, or null to follow the fleet default."
+ }
+ }
+ },
+ "effective": {
+ "$ref": "#/components/schemas/RustAudience"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustVisibilityUpdate": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "fleet": {
+ "$ref": "#/components/schemas/RustAudience"
+ },
+ "servers": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "staff",
+ "signed_in",
+ "public",
+ null
+ ],
+ "items": {}
+ }
+ }
+ },
+ "example": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "string",
+ "example": "public"
+ },
+ "pvp": {}
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"RustSidecarProbe": {
"type": "object",
"properties": {
--
2.49.1
From c94271104f3ba2f5f74c4f86b9e911f239276cae Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 23 Sep 2026 05:14:18 -0500
Subject: [PATCH 11/51] feat(rust): Teams from first-party clans (phase 9,
protocol 6)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A first-party Rust clan is a Team (R5). This module becomes the site's
Team provider and answers core from the plugin's `clans` board. Design
of record: docs/modules/rust/PLAN.md §24, D47-D58.
- The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's
identity is :: (D52), because the game
restarts clan ids whenever its clan database version changes.
- The provider (D53): getTeams is complete only when every server's
board is fresh, supported and untruncated. It is partial when some
are, and refuses when none are. Freshness is judged by the website's
clock, from when the board's `t` last advanced.
- Only a complete board may mark a clan gone. A board at the game's
100-clan ceiling (D55), or one with an unreadable row, proves nothing
about what it leaves out.
- Leadership is diffed board to board and published (D54). The five clan
events are published as team.* kinds, and written to the Team feed as
members-only lines (D49).
- Core only writes feed items for a Team it already holds. So the last 10
minutes of clan events are re-offered on each board refresh, deduped by
a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key
would be truncated into collisions.
- projectRoster and the clan page share one audience rule (D48): the
clan's linked members and staff by default, re-read from the users row.
The setting lives on Admin > Rust visibility, which also warns about
uMod Clans (D47) and the ceiling.
- Public: GET servers/:id/clans (the list is public, D58) and
GET clans/:externalId. The client adds a Clans tab and
/rust/clans/:externalId, with three module slots for core's notify,
activity and forum contributions (D56).
- Linking and unlinking an account ask core to reconcile Teams (D57).
- The clan kinds are staff-class in the public feed allowlist.
- PROTOCOL_VERSION is now 6.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
README.md | 15 +-
client/src/api.js | 16 +
client/src/components/Clans.jsx | 118 +++
client/src/entry.jsx | 21 +
client/src/routes/admin/Visibility.jsx | 84 +-
client/src/routes/public/Clan.jsx | 161 ++++
client/src/routes/public/ServerDetail.jsx | 5 +
client/test/registration.test.js | 17 +
routes.manifest.json | 10 +
server/catalogue.js | 11 +-
server/core.js | 18 +
server/db/purge.sql | 3 +
server/db/schema.sql | 91 ++
server/index.js | 17 +-
server/ingest.js | 35 +
server/model/clans/clans.db.js | 298 +++++++
server/model/clans/clans.model.js | 573 +++++++++++++
server/model/clans/teamProvider.js | 202 +++++
server/model/links/links.model.js | 27 +-
server/model/visibility/visibility.model.js | 61 +-
server/router/admin/visibility.controller.js | 20 +-
server/router/admin/visibility.router.js | 10 +-
server/router/public/rust.controller.js | 58 +-
server/router/public/rust.router.js | 28 +
server/sidecarClient.js | 12 +-
server/swagger/doc.js | 105 +++
server/test/_fakes.js | 10 +
server/test/catalogue.test.js | 13 +-
server/test/clans.test.js | 552 ++++++++++++
server/test/entry.test.js | 16 +-
server/test/links.test.js | 25 +
server/test/visibility.test.js | 22 +
swagger-fragment.json | 834 ++++++++++++++++++-
33 files changed, 3456 insertions(+), 32 deletions(-)
create mode 100644 client/src/components/Clans.jsx
create mode 100644 client/src/routes/public/Clan.jsx
create mode 100644 server/model/clans/clans.db.js
create mode 100644 server/model/clans/clans.model.js
create mode 100644 server/model/clans/teamProvider.js
create mode 100644 server/test/clans.test.js
diff --git a/README.md b/README.md
index 704cffa..a12cfe5 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,15 @@ rows here; the website core never learns there is more than one.
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
| Public | `GET …/servers/:id/wipes` and `…/online` |
+| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
+| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page |
-| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
+| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
+| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
+| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
| Slot | `site.footer.status` — a live server/player count in core's footer |
**Nothing names who is online by default.** The Online list, every feed item that says a named
@@ -62,7 +66,14 @@ Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_pres
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
-The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
+**Teams come from Rust's own clans**, not from the uMod Clans plugin, which is optional and whose
+clans never become Teams. A clan's roster reaches its own members and staff unless an operator
+widens it in Admin → Rust visibility; its name, colour, score and count are public. The game lists
+at most 100 clans per server, and a server at that ceiling answers core partially, so core never
+removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
+one module**: core's installer refuses a second.
+
+The rest of the module — notifications,
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
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.
diff --git a/client/src/api.js b/client/src/api.js
index 28df9ac..023325a 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -47,6 +47,21 @@ export const servers = {
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
+
+ // Phase 9. The clan list is public (D58): name, colour, score and member count
+ // name nobody. `board` says whether the list can be trusted right now.
+ clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`),
+}
+
+// One clan. Its roster comes back only for a viewer inside the operator's roster
+// audience (D48) — clan members and staff by default — and `roster.visible`
+// says which answer this was, so a page can explain an empty roster rather than
+// imply an empty clan.
+//
+// The id carries colons (`::`). They are legal in a path
+// segment, and encoded anyway so that a server slug is never read as structure.
+export const clans = {
+ get: (externalId) => req(`/public/rust/clans/${encodeURIComponent(externalId)}`),
}
/**
@@ -231,6 +246,7 @@ export { BASE, query }
export default {
servers,
+ clans,
playerServers,
playerLinks,
playerPermissions,
diff --git a/client/src/components/Clans.jsx b/client/src/components/Clans.jsx
new file mode 100644
index 0000000..7786a2b
--- /dev/null
+++ b/client/src/components/Clans.jsx
@@ -0,0 +1,118 @@
+// ── The clans on one server ───────────────────────────────────────────────
+//
+// Rust's OWN clans (R5), best score first. Public at every setting (D58): a
+// clan's name, colour, score and member count name nobody. Who is IN a clan is
+// the roster, and that lives on the clan's own page behind the operator's
+// roster audience (D48).
+//
+// **The list is only as good as the board it came from**, and the answer says
+// how good that is. Three cases would all look like an empty list if rendered
+// bare, and they are three different sentences:
+//
+// • the bridge cannot read this server's clans at all (an older plugin, or a
+// Nexus server whose clans live elsewhere) — "unavailable";
+// • the game's clan system is switched off — "this server has no clans";
+// • it can, and there are none — "nobody has founded one yet".
+//
+// And a board at the game's 100-clan ceiling (D55) says there may be more.
+
+import { Link } from 'react-router-dom'
+import { ErrorState, Loading, useAsync } from '../core.js'
+import Empty from './Empty.jsx'
+import { count } from '../lib/format.js'
+import api from '../api.js'
+
+export default function Clans({ serverId }) {
+ const { data, loading, error } = useAsync(() => api.servers.clans(serverId), [serverId])
+
+ if (loading) return
+ if (error) return
+
+ const clans = (data && data.clans) || []
+ const board = (data && data.board) || {}
+
+ if (clans.length === 0) {
+ if (!board.supported) {
+ return (
+
+ )
+ }
+ if (board.enabled === false) {
+ return
+ }
+ return
+ }
+
+ return (
+ <>
+ {board.truncated && (
+
+ The game lists at most 100 clans, by score, so there may be more on this server than are shown here.
+
+ >
+ )
+}
+
+/** Where a clan's page is: the same template the Team provider hands core. */
+export function clanPath(externalId) {
+ return `/rust/clans/${encodeURIComponent(externalId)}`
+}
+
+/**
+ * A clan's colour, as a small square. The server has already checked it is a
+ * `#rrggbb` — it ends up in a style — and a clan with no colour gets an outline
+ * rather than a guess.
+ */
+export function Swatch({ color, size = 12 }) {
+ return (
+
+ )
+}
+
+function capitalise(text) {
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : text
+}
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index 0c6950d..e2dc353 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -20,6 +20,7 @@ import { registry, coreApiVersion } from './core.js'
import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
+import Clan from './routes/public/Clan.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
@@ -82,6 +83,10 @@ registry.registerRoutes(ID, {
public: [
{ path: '', element: },
{ path: 'servers/:id', element: },
+ // Phase 9 (D56). Not nested under its server: core links here from Team
+ // notification email through `pageUrlTemplate`, which substitutes
+ // `{externalId}` and nothing else — and the server is inside that id.
+ { path: 'clans/:externalId', element: },
],
player: [{ path: '', element: }],
admin: [
@@ -175,6 +180,22 @@ registry.registerExtension(ID, 'site.footer.status', FooterStatus)
// no linked Steam account, which is most of them.
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
+// ── Inverted slots: core's Team contributions on OUR clan page ─────────────
+//
+// §3.7a. A clan is a Team (R5), and core renders no Team page because it does
+// not own the word "clan". So the page is `routes/public/Clan.jsx` and core
+// contributes the three things only it can render — into places this module
+// names, in this module's vocabulary. Core offers a CONTRIBUTION; it never names
+// a slot, which is what lets a second game use the same contract as module-uo.
+//
+// One slot per PLACE (D56): a slot holds one component, and a collapsed slot
+// would hand core the decision about where each part sits on a page it does not
+// own. Asking for a contribution core does not offer throws here, at
+// registration — a typo fails loudly rather than rendering nothing for ever.
+registry.declareModuleSlot(ID, 'rust.clan.header', { core: 'team.notify' })
+registry.declareModuleSlot(ID, 'rust.clan.detail', { core: 'team.activity' })
+registry.declareModuleSlot(ID, 'rust.clan.forum', { core: 'team.forum' })
+
// `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/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx
index 3671adb..428d2fc 100644
--- a/client/src/routes/admin/Visibility.jsx
+++ b/client/src/routes/admin/Visibility.jsx
@@ -12,6 +12,13 @@
// The page says what "who is online" covers, because it is wider than the tab
// of the same name: the killfeed, chat and joins in the feed, and the
// leaderboard's "last seen" all name a player who was on at a given moment.
+//
+// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
+// defaults to the clan's own members and staff, and widening it widens online
+// status too, because a roster row carries it — the page says so. The same card
+// lists each server's clan board: a server whose clans cannot be read, one at
+// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
+// whose clans are a separate system and never Teams (D47).
import { useCallback, useEffect, useState } from 'react'
@@ -26,6 +33,18 @@ const LABEL = {
public: 'Everyone',
}
+const CLAN_LABEL = {
+ members: 'The clan’s members and staff',
+ signed_in: 'Signed-in members',
+ public: 'Everyone',
+}
+
+const CLAN_DESCRIBE = {
+ members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
+ signed_in: 'Anybody with an account on this site.',
+ public: 'Anybody at all, signed in or not.',
+}
+
const DESCRIBE = {
staff: 'Admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
@@ -66,6 +85,7 @@ export default function Visibility() {
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
const [fleet, setFleet] = useState('staff')
+ const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
@@ -76,6 +96,7 @@ export default function Visibility() {
// the site's word rather than what this page sent.
const load = useCallback((state) => {
setFleet(state.presence.fleet)
+ setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
}, [])
@@ -91,7 +112,9 @@ export default function Visibility() {
const dirtyFleet = fleet !== data.presence.fleet
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
- const dirty = dirtyFleet || dirtyServers.length > 0
+ const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
+ const dirtyClans = clanRoster !== clans.roster
+ const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
@@ -104,6 +127,7 @@ export default function Visibility() {
try {
const body = {}
if (dirtyFleet) body.fleet = fleet
+ if (dirtyClans) body.clanRoster = clanRoster
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
@@ -175,6 +199,32 @@ export default function Visibility() {
)}
+
+
+
+ {CLAN_DESCRIBE[clanRoster]}
+
+
+ Each clan’s name, colour, score and member count are always public.
+
+ {clanRoster !== 'members' && (
+
+ A roster also shows which members are online right now, so this shows who is on to{' '}
+ {clanRoster === 'public' ? 'everyone' : 'every signed-in member'} as well.
+
+ )}
+
+
+
{busy ? 'Saving…' : 'Save'}
@@ -186,6 +236,38 @@ export default function Visibility() {
)
}
+/**
+ * What each server's clan board says about itself. Only the servers with
+ * something to report are listed: a board that is current, complete and read
+ * normally is the case that needs no sentence.
+ */
+function ClanBoards({ servers }) {
+ const notes = []
+ for (const s of servers) {
+ if (s.umodClans) {
+ notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the game’s own, and only the game’s clans appear on this site.'])
+ }
+ if (!s.supported) {
+ notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
+ } else if (s.truncated) {
+ notes.push([s, 'is at the game’s limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
+ } else if (!s.fresh) {
+ notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
+ }
+ }
+ if (!notes.length) return null
+ return (
+
+ )
+}
+
const selectStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
diff --git a/client/src/routes/public/Clan.jsx b/client/src/routes/public/Clan.jsx
new file mode 100644
index 0000000..1de2046
--- /dev/null
+++ b/client/src/routes/public/Clan.jsx
@@ -0,0 +1,161 @@
+// ── One clan ──────────────────────────────────────────────────────────────
+//
+// A first-party Rust clan is a Team (R5), and this is its page. Core owns the
+// Team — the reconciler, the access rules, the activity feed, the forum — but
+// not the word "clan", so it publishes no Team page of its own (MODULE_API.md
+// §3.7a). The page is this module's, and the three parts only core can render
+// are contributed into places this page names:
+//
+// rust.clan.header ← core's `team.notify` (above the roster: an action ON the page)
+// rust.clan.detail ← core's `team.activity` (the members-only feed, D49)
+// rust.clan.forum ← core's `team.forum`
+//
+// One slot per PLACE, as module-uo does, so core never decides the layout of a
+// page it does not own. **Every slot may be empty** — a core without Teams, a
+// deployment with the forum switched off, a clan whose Team core has not created
+// yet — and the page has to read correctly anyway. That is the phase criterion,
+// and it is why nothing here says "see below" about something core may not put
+// below.
+//
+// The roster comes from this module's own board, through the same function core
+// asks when it projects a roster (D48), so the two cannot disagree about who may
+// look. Below the audience the clan is still described — its name, score and
+// count are public (D58) — and the roster says who may see it instead.
+
+import { useParams, Link } from 'react-router-dom'
+import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
+import Empty from '../../components/Empty.jsx'
+import { Swatch } from '../../components/Clans.jsx'
+import { count, day } from '../../lib/format.js'
+import api from '../../api.js'
+
+const ID = 'rust'
+
+export default function Clan() {
+ const { externalId } = useParams()
+ const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ // A mistyped or out-of-date address is not an outage, and must not read as
+ // one — the same rule the server page learned in phase 4.
+ if (error || !data || !data.clan) {
+ const missing = !error || error.status === 404
+ return (
+
+
+ {!missing && }
+
+
+ {m.name || 'Unknown player'}
+ {m.leader && (
+ Leader
+ )}
+
+ {m.role && !m.leader && (
+ {m.role}
+ )}
+ {/* Inside the roster audience by construction (D48): a viewer who may
+ not see the roster sees no row to hang this on. */}
+
+ {m.online ? 'online' : ''}
+
+
+ ))}
+
+ )
+}
+
+/** Why the roster was withheld, in words a visitor can act on. */
+function withheld(audience) {
+ if (audience === 'signed_in') return 'Sign in to see who is in this clan.'
+ if (audience === 'public') return 'This site is not showing clan rosters right now.'
+ return 'Only this clan’s own members, with a linked Rust account, and this site’s staff can see who is in it.'
+}
diff --git a/client/src/routes/public/ServerDetail.jsx b/client/src/routes/public/ServerDetail.jsx
index 2750826..bb234ea 100644
--- a/client/src/routes/public/ServerDetail.jsx
+++ b/client/src/routes/public/ServerDetail.jsx
@@ -23,6 +23,7 @@
import { useSearchParams, useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
+import Clans from '../../components/Clans.jsx'
import Feed from '../../components/Feed.jsx'
import Leaderboard from '../../components/Leaderboard.jsx'
import Online from '../../components/Online.jsx'
@@ -37,6 +38,8 @@ const TABS = [
{ id: 'leaderboard', label: 'Leaderboard' },
{ id: 'online', label: 'Online' },
{ id: 'wipes', label: 'Wipes' },
+ // Phase 9. The list is public (D58); each clan's roster is on its own page.
+ { id: 'clans', label: 'Clans' },
]
export default function ServerDetail() {
@@ -152,6 +155,8 @@ export default function ServerDetail() {
{tab === 'online' && }
+ {tab === 'clans' && }
+
{tab === 'wipes' && (
{
}
})
+it('the clan page gets all three of core’s Team contributions, one per place (phase 9, D56)', () => {
+ // Core contributes three things to a Team page it does not own. Each has its
+ // own place on the clan page, so no contribution is decided by another's
+ // position — and a slot missing here is a clan page with no feed, no forum or
+ // no notification switch, with nothing logged anywhere.
+ const byName = Object.fromEntries(registered.declaredSlots.map((s) => [s.name, s.wants]))
+ assert.deepEqual(byName, {
+ 'rust.clan.header': 'team.notify',
+ 'rust.clan.detail': 'team.activity',
+ 'rust.clan.forum': 'team.forum',
+ })
+
+ // And the page is at the address the Team provider hands core.
+ const paths = registered.routes.public.map((r) => r.path)
+ assert.ok(paths.includes('rust/clans/:externalId'), paths.join(', '))
+})
+
it('registers under exactly one module id, matching the manifest', () => {
const owners = new Set([
...Object.values(registered.routes).flat().map((r) => r.moduleId),
diff --git a/routes.manifest.json b/routes.manifest.json
index f3d3484..b8a8bef 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -96,6 +96,11 @@
"path": "/api/v1/player/rust/servers",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/rust/clans/:externalId",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/public/rust/servers",
@@ -106,6 +111,11 @@
"path": "/api/v1/public/rust/servers/:id",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/rust/servers/:id/clans",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/events",
diff --git a/server/catalogue.js b/server/catalogue.js
index c09b8df..f39ce84 100644
--- a/server/catalogue.js
+++ b/server/catalogue.js
@@ -80,6 +80,15 @@ const STAFF_KINDS = Object.freeze([
// 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',
+ // Protocol 6. Clan membership, which the org lead made members-only (D49):
+ // who joined which clan, and who threw whom out, is the clan's business. It
+ // reaches a clan's own members through core's Team feed, where core resolves
+ // who is a member, and it reaches the server's public feed not at all.
+ 'clan.created',
+ 'clan.disbanded',
+ 'clan.member.added',
+ 'clan.member.left',
+ 'clan.member.kicked',
])
/**
@@ -109,7 +118,7 @@ const PRESENCE_KINDS = Object.freeze([
'player.tally',
])
-/** Every kind protocol 3 defines. */
+/** Every kind the protocol defines, through protocol 6. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)
diff --git a/server/core.js b/server/core.js
index f8ad635..e68f0a9 100644
--- a/server/core.js
+++ b/server/core.js
@@ -149,6 +149,24 @@ module.exports = {
// would be worse, since a module has more than one thing it could reconcile.
reconcileEvents: () => need().events.reconcile(),
+ // Teams (MODULE_API.md §2.3, 1.6.0) — the push half of the provider this
+ // module registers (`model/clans/teamProvider.js`). Three calls, all
+ // fire-and-forget, and core's contract is that none of them can make this
+ // module's call site slow or turn a background failure into its error:
+ //
+ // publish(event) a membership or leadership change, as it happened
+ // reconcile({reason}) "the set may have changed, come and ask" — debounced
+ // pushActivity(items) the per-Team feed, idempotent on each item's dedupeKey
+ //
+ // Correctness comes from reconciliation either way; `publish` only makes a
+ // change visible sooner. Wrapped as calls, like `emit`, so a file that takes
+ // `core.teams` at require time still resolves `ctx` when it is used.
+ teams: {
+ publish: (event) => need().teams.publish(event),
+ reconcile: (options) => need().teams.reconcile(options),
+ pushActivity: (items) => need().teams.activity.push(items),
+ },
+
// Deployment facts. `moduleRoot` is the absolute path to `modules//` — the
// only correct way to find a file you shipped, because the working directory is
// core's and the module's location is the loader's business.
diff --git a/server/db/purge.sql b/server/db/purge.sql
index 8882277..deec5e9 100644
--- a/server/db/purge.sql
+++ b/server/db/purge.sql
@@ -20,6 +20,9 @@
-- registrant owned what.
-- Phase 7b.
+DROP TABLE IF EXISTS rust_clan_boards;
+DROP TABLE IF EXISTS rust_clan_members;
+DROP TABLE IF EXISTS rust_clans;
DROP TABLE IF EXISTS rust_settings;
DROP TABLE IF EXISTS rust_config_writes;
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 55ded29..cb7f68a 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -701,3 +701,94 @@ CREATE TABLE IF NOT EXISTS rust_settings (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;
+
+
+-- ── Clans (phase 9, protocol 6) ───────────────────────────────────────────
+--
+-- Rust's FIRST-PARTY clans, which this module answers core's Team questions
+-- from (R5, PLAN.md §24). Three tables, and the split is the same one the rest
+-- of this file makes: what a board said (`rust_clans`, `rust_clan_members`),
+-- and what this module knows about the board itself (`rust_clan_boards`).
+--
+-- **`external_id` is the Team's identity, and it is NOT the game's clan id.**
+-- It is `::` (D52). The game keeps clans in
+-- `clans..db` with the version hard-coded, so a game update that bumps
+-- it starts a fresh file whose ids restart at 1. Keyed on the id alone, the new
+-- clan #1 would inherit the old clan #1's Team — its forum, its members-only
+-- history — and core would read the swap as a rename.
+--
+-- **A clan that leaves the board is marked gone, not deleted.** `gone_at` is set
+-- only when a board that is COMPLETE for its server no longer carries it: a
+-- board truncated at the game's 100-clan ceiling (D55) proves nothing about a
+-- clan it does not list. A gone clan is not offered to core, which is what lets
+-- core archive its Team.
+CREATE TABLE IF NOT EXISTS rust_clans (
+ external_id VARCHAR(160) NOT NULL PRIMARY KEY,
+ server_id VARCHAR(64) NOT NULL,
+ clan_id BIGINT NOT NULL,
+ created_ms BIGINT NOT NULL,
+ name VARCHAR(191) NOT NULL,
+ -- `#rrggbb`, as the plugin spells it. Stored as sent rather than parsed, and
+ -- re-checked on the way out (`model/clans`), because it ends up in a style.
+ color VARCHAR(16) NULL,
+ score BIGINT NOT NULL DEFAULT 0,
+ member_count INT UNSIGNED NOT NULL DEFAULT 0,
+ max_members INT UNSIGNED NULL,
+ first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ gone_at DATETIME NULL,
+ CONSTRAINT fk_rust_clans_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
+ KEY idx_rust_clans_server (server_id, gone_at),
+ KEY idx_rust_clans_game_id (server_id, clan_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- One row per member per clan, replaced whole from each board.
+--
+-- `rank` is the role's rank, and **rank 1 is leader** — the game's own rule, and
+-- several members may hold it. It is NULL when the member's role id matched no
+-- role on the board: "not known" must never be read as "leads this clan".
+--
+-- There is deliberately no `last_seen`. The game has one; the plugin does not
+-- send it, because when somebody was last on is presence (PLAN.md §23).
+CREATE TABLE IF NOT EXISTS rust_clan_members (
+ external_id VARCHAR(160) NOT NULL,
+ steam_id VARCHAR(32) NOT NULL,
+ name VARCHAR(191) NULL,
+ role_rank INT NULL,
+ role_name VARCHAR(64) NULL,
+ joined_ms BIGINT NULL,
+ PRIMARY KEY (external_id, steam_id),
+ CONSTRAINT fk_rust_clan_members_clan
+ FOREIGN KEY (external_id) REFERENCES rust_clans (external_id) ON DELETE CASCADE,
+ KEY idx_rust_clan_members_steam (steam_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- What this module knows about each server's clan board, as opposed to what the
+-- board said.
+--
+-- **Freshness is judged by THIS side's clock.** `board_t` is the plugin's own
+-- timestamp on the board; `seen_at` is when this module first saw that value.
+-- A board whose `t` stops advancing is a game that stopped talking, and the
+-- age of `seen_at` is how long ago that was — comparing `board_t` to the
+-- website's clock instead would let a game host whose clock runs ahead make a
+-- stale board look current for as long as the skew lasts.
+--
+-- `umod_clans` is whether the optional uMod Clans plugin is loaded on that
+-- server (D47): its clans are a separate system and never Teams, and the admin
+-- page says so.
+CREATE TABLE IF NOT EXISTS rust_clan_boards (
+ server_id VARCHAR(64) NOT NULL PRIMARY KEY,
+ board_t BIGINT NULL,
+ seen_at DATETIME NULL,
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ supported TINYINT(1) NOT NULL DEFAULT 0,
+ truncated TINYINT(1) NOT NULL DEFAULT 0,
+ backend VARCHAR(64) NULL,
+ reason VARCHAR(255) NULL,
+ umod_clans TINYINT(1) NOT NULL DEFAULT 0,
+ clan_count INT UNSIGNED NOT NULL DEFAULT 0,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_rust_clan_boards_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/server/index.js b/server/index.js
index 80ebf2b..aff5741 100644
--- a/server/index.js
+++ b/server/index.js
@@ -51,6 +51,7 @@ module.exports = function register(ctx, api) {
const playerRust = require('./router/player/rust.router')
const adminRust = require('./router/admin/rust.router')
const usersRust = require('./router/admin/usersRust.router')
+ const teamProvider = require('./model/clans/teamProvider')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -93,6 +94,17 @@ module.exports = function register(ctx, api) {
// slot and fails the load outright when named there.
api.registerExtension('admin.users.detail', usersRust)
+ // Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
+ // becomes the deployment's one authoritative source of them. Core asks; the
+ // provider answers from the clan boards (`model/clans`), and refuses rather
+ // than guessing whenever no board is current.
+ //
+ // **One provider per deployment**, so a site running module-uo as well cannot
+ // have both — the second registration is a collision core reports against the
+ // module that made it. That is core's rule and a real constraint on a mixed
+ // UO + Rust site; it is recorded in §24 rather than worked around here.
+ api.registerTeamProvider(teamProvider)
+
// 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.
@@ -105,8 +117,8 @@ module.exports = function register(ctx, api) {
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
- // Everything else this module will register — the Team provider, the event
- // triggers and audiences, the engagement seeds, the four event catalogues, the
+ // Everything else this module will register — the event triggers and
+ // audiences, the engagement seeds, the four event catalogues, the
// 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
@@ -117,5 +129,6 @@ module.exports = function register(ctx, api) {
version: require('../module.json').version,
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
+ teams: 'first-party clans',
})
}
diff --git a/server/ingest.js b/server/ingest.js
index 8256293..2c33545 100644
--- a/server/ingest.js
+++ b/server/ingest.js
@@ -33,6 +33,7 @@
const core = require('./core')
+const clans = require('./model/clans/clans.model')
const db = require('./model/events/events.db')
const links = require('./model/links/links.model')
const permissionsDb = require('./model/permissions/permissions.db')
@@ -191,6 +192,24 @@ async function apply(serverId, item) {
await permissionsDb.markDirty(serverId)
break
+ // ── Protocol 6: first-party clans ──────────────────────────────────────
+ //
+ // Each one is told to core as it happens (`ctx.teams.publish`) and written
+ // to the clan's Team feed as a members-only line (D49). Neither is the
+ // record: the `clans` board the plugin re-sends a few seconds later is what
+ // the store is rebuilt from, so an event this module never saw costs a
+ // feed line and nothing else.
+ //
+ // No `touchPlayer` here, on purpose: it moves `last_seen`, and a kick is
+ // done TO somebody who may be offline. `model/clans` notes names without it.
+ case 'clan.created':
+ case 'clan.disbanded':
+ case 'clan.member.added':
+ case 'clan.member.left':
+ case 'clan.member.kicked':
+ await clans.applyEvent(serverId, frame)
+ break
+
default:
// Stored, not counted. Moderation frames, the server lifecycle, and
// anything a newer protocol sends that this build does not understand.
@@ -284,6 +303,22 @@ async function applyBoards(serverId, boards) {
if (presence && Array.isArray(presence.players)) {
await db.replacePresence(serverId, presence.players)
}
+
+ // Clans only once the game has spoken at all. A sidecar that has never heard
+ // from its plugin holds no boards, and recording "no clan board" then would
+ // blame the plugin's protocol for a game server that is simply not up. Left
+ // alone, the stored board ages past fresh on its own, which is the true answer.
+ if (boards && boards['server.hello']) {
+ // Fenced: clans are the one board here that core's Teams depend on, and a
+ // failure applying them must cost the clans rather than the presence board
+ // above or the server state the caller writes next.
+ try {
+ await clans.applyBoard(serverId, boards.clans)
+ await clans.reofferActivity(serverId)
+ } catch (err) {
+ log.warn('could not apply the clan board', { server: serverId, error: err.message })
+ }
+ }
}
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }
diff --git a/server/model/clans/clans.db.js b/server/model/clans/clans.db.js
new file mode 100644
index 0000000..a3adf9e
--- /dev/null
+++ b/server/model/clans/clans.db.js
@@ -0,0 +1,298 @@
+// ── SQL for first-party clans ─────────────────────────────────────────────
+//
+// Three tables (see `schema.sql`): the clans a board carried, their members, and
+// what this module knows about each server's board. Raw parameterised SQL, as
+// everywhere in this module; the model decides what any of it means.
+
+const core = require('../../core')
+
+const CLANS = 'rust_clans'
+const MEMBERS = 'rust_clan_members'
+const BOARDS = 'rust_clan_boards'
+const LINKS = 'rust_account_links'
+const PLAYERS = 'rust_players'
+const SERVERS = 'rust_servers'
+
+// ── Boards ─────────────────────────────────────────────────────────────────
+
+/** One server's board record, or null when it has never sent one. */
+async function getBoard(serverId) {
+ const rows = await core.query(
+ `SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported,
+ truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount
+ FROM ${BOARDS} WHERE server_id = ?`,
+ [serverId],
+ )
+ return rows[0] || null
+}
+
+/** Every configured server beside its board record, which may be absent. */
+async function listBoards() {
+ return core.query(
+ `SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled,
+ b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated,
+ b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount
+ FROM ${SERVERS} s
+ LEFT JOIN ${BOARDS} b ON b.server_id = s.id
+ ORDER BY s.sort_order ASC, s.id ASC`,
+ )
+}
+
+/**
+ * Records what a board said about itself.
+ *
+ * `seenAt` is passed only when the board's `t` ADVANCED, and is then the
+ * website's own now; otherwise the stored one is kept. That is the whole of the
+ * freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to
+ * every caller to read-then-write.
+ */
+async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) {
+ await core.query(
+ `INSERT INTO ${BOARDS}
+ (server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at)
+ VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ON DUPLICATE KEY UPDATE
+ board_t = VALUES(board_t),
+ seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'},
+ enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated),
+ backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans),
+ clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`,
+ [
+ serverId,
+ boardT,
+ enabled ? 1 : 0,
+ supported ? 1 : 0,
+ truncated ? 1 : 0,
+ backend || null,
+ reason ? String(reason).slice(0, 255) : null,
+ umodClans ? 1 : 0,
+ clanCount || 0,
+ ],
+ )
+}
+
+// ── Clans ──────────────────────────────────────────────────────────────────
+
+/** Every clan this module holds for one server, gone or not. */
+async function listClansForServer(serverId) {
+ return core.query(
+ `SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name,
+ member_count AS memberCount, gone_at AS goneAt
+ FROM ${CLANS} WHERE server_id = ?`,
+ [serverId],
+ )
+}
+
+/**
+ * Every member of one server's current clans, as the board last stated them,
+ * for diffing the next board against. The name is the BOARD's, not the player
+ * table's, because it is compared with the board.
+ */
+async function listMembersForServer(serverId) {
+ return core.query(
+ `SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank,
+ m.role_name AS role, m.name
+ FROM ${MEMBERS} m
+ JOIN ${CLANS} c ON c.external_id = m.external_id
+ WHERE c.server_id = ? AND c.gone_at IS NULL`,
+ [serverId],
+ )
+}
+
+async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) {
+ await core.query(
+ `INSERT INTO ${CLANS}
+ (external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members,
+ first_seen, updated_at, gone_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
+ ON DUPLICATE KEY UPDATE
+ name = VALUES(name), color = VALUES(color), score = VALUES(score),
+ member_count = VALUES(member_count), max_members = VALUES(max_members),
+ updated_at = CURRENT_TIMESTAMP, gone_at = NULL`,
+ [externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers],
+ )
+}
+
+/**
+ * Replaces one clan's members.
+ *
+ * Delete then insert, not wrapped in a transaction — the same trade the presence
+ * board makes (`events.db.replacePresence`): a fraction of a second in which a
+ * roster read might come back short, against holding a lock on a table that core's
+ * reconciler and two public routes read.
+ */
+async function replaceMembers(externalId, members) {
+ await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId])
+
+ for (const m of members) {
+ // eslint-disable-next-line no-await-in-loop
+ await core.query(
+ `INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank),
+ role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`,
+ [externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs],
+ )
+ }
+}
+
+/** Marks clans gone. Their members are removed with them; a gone clan has no roster. */
+async function markGone(externalIds) {
+ if (!externalIds.length) return
+ const marks = externalIds.map(() => '?').join(', ')
+ await core.query(
+ `UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`,
+ externalIds,
+ )
+ await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds)
+}
+
+/** One clan by its Team identity, with its server's name, or null. */
+async function findClan(externalId) {
+ const rows = await core.query(
+ `SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
+ c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score,
+ c.member_count AS memberCount, c.max_members AS maxMembers,
+ c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt
+ FROM ${CLANS} c
+ JOIN ${SERVERS} s ON s.id = c.server_id
+ WHERE c.external_id = ?`,
+ [externalId],
+ )
+ return rows[0] || null
+}
+
+/**
+ * The newest clan this module holds under a game id on one server, or null.
+ *
+ * The fallback for the one event that can arrive without a creation time
+ * (`clan.member.added`, when the plugin could not read the clan back). Newest,
+ * because an id that the game has re-used belongs to the clan that re-used it.
+ */
+async function findByGameId(serverId, clanId) {
+ const rows = await core.query(
+ `SELECT external_id AS externalId, name
+ FROM ${CLANS} WHERE server_id = ? AND clan_id = ?
+ ORDER BY created_ms DESC LIMIT 1`,
+ [serverId, clanId],
+ )
+ return rows[0] || null
+}
+
+/** Every clan still on a board, for core's `getTeams`. */
+async function listActiveClans() {
+ return core.query(
+ `SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
+ c.name, c.color, c.score, c.member_count AS memberCount
+ FROM ${CLANS} c
+ JOIN ${SERVERS} s ON s.id = c.server_id
+ WHERE c.gone_at IS NULL
+ ORDER BY c.server_id ASC, c.score DESC, c.name ASC`,
+ )
+}
+
+/** One server's clans still on its board, for the public Clans tab. Best first. */
+async function listPublicForServer(serverId) {
+ return core.query(
+ `SELECT external_id AS externalId, name, color, score, member_count AS memberCount,
+ max_members AS maxMembers
+ FROM ${CLANS}
+ WHERE server_id = ? AND gone_at IS NULL
+ ORDER BY score DESC, name ASC`,
+ [serverId],
+ )
+}
+
+/**
+ * One clan's roster, with the website account behind each member when there is
+ * one and whether they are on the clan's server right now.
+ *
+ * Three joins, all of this module's own tables: the link (a Steam id to a user),
+ * the player table (the newest name the game has sent for them) and the presence
+ * board. Presence is joined on the CLAN's server — a member on another server of
+ * the fleet is not online here.
+ */
+async function listMembers(externalId) {
+ return core.query(
+ `SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank,
+ m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId,
+ (pr.steam_id IS NOT NULL) AS online
+ FROM ${MEMBERS} m
+ JOIN ${CLANS} c ON c.external_id = m.external_id
+ LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id
+ LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id
+ LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id
+ WHERE m.external_id = ?
+ ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`,
+ [externalId],
+ )
+}
+
+/** Whether a website user holds a linked Steam account that is a member of this clan. */
+async function userIsMember(externalId, userId) {
+ const rows = await core.query(
+ `SELECT 1 AS yes
+ FROM ${MEMBERS} m
+ JOIN ${LINKS} l ON l.steam_id = m.steam_id
+ WHERE m.external_id = ? AND l.user_id = ?
+ LIMIT 1`,
+ [externalId, userId],
+ )
+ return rows.length > 0
+}
+
+/**
+ * Recent clan events for one server, oldest first, for re-offering their feed
+ * items to core until the Team they name exists (see `model/clans`).
+ */
+async function recentClanEvents(serverId, sinceMs) {
+ return core.query(
+ `SELECT id, kind, t, raw
+ FROM rust_events
+ WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ?
+ ORDER BY t ASC, id ASC
+ LIMIT 200`,
+ [serverId, sinceMs],
+ )
+}
+
+/**
+ * Notes a player's name WITHOUT touching `last_seen`.
+ *
+ * `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that
+ * says a player was on and wrong for a clan frame: a kick is done TO somebody who
+ * may be offline, and a leaderboard's "last seen" would then read as a presence
+ * signal for a player who never connected (PLAN.md §23).
+ *
+ * A player this module has never heard of still gets a on the new row,
+ * because the column is NOT NULL; what matters is that an existing row's is left
+ * alone, and every surface that reads it is behind the presence gate anyway.
+ */
+async function rememberName(steamId, name) {
+ if (!steamId) return
+ await core.query(
+ `INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
+ VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`,
+ [steamId, name || null],
+ )
+}
+
+module.exports = {
+ getBoard,
+ listBoards,
+ putBoard,
+ listClansForServer,
+ listMembersForServer,
+ upsertClan,
+ replaceMembers,
+ markGone,
+ findClan,
+ findByGameId,
+ listActiveClans,
+ listPublicForServer,
+ listMembers,
+ userIsMember,
+ recentClanEvents,
+ rememberName,
+}
diff --git a/server/model/clans/clans.model.js b/server/model/clans/clans.model.js
new file mode 100644
index 0000000..47477c4
--- /dev/null
+++ b/server/model/clans/clans.model.js
@@ -0,0 +1,573 @@
+// ── First-party clans: the board, the events, and who may see a roster ────
+//
+// Rust's OWN clan system, which this module turns into core's Teams (R5,
+// PLAN.md §24). Three jobs, one file, because all three have to agree on what a
+// clan's identity is:
+//
+// applyBoard a `clans` snapshot → the store, plus what changed
+// applyEvent a `clan.*` event → core (publish) and the Team feed
+// canSeeRoster D48's audience, for core's `projectRoster` and our own page
+//
+// ── The identity (D52) ────────────────────────────────────────────────────
+//
+// `::`. The game's clan id alone is not one: its
+// database file carries a hard-coded version, so a game update that bumps it
+// starts a fresh file and ids restart at 1. Keyed on the id, the new clan #1
+// would inherit the old clan #1's Team, forum and history.
+//
+// ── What a board may conclude, and what it may not ───────────────────────
+//
+// A board is authoritative for the clans it CARRIES. It is authoritative about
+// the clans it does NOT carry only when it is complete: a board truncated at the
+// game's 100-clan ceiling (D55), or one with a row this build could not read,
+// proves nothing about a clan it leaves out, and marking that clan gone would
+// hand core an archive on no evidence.
+
+const crypto = require('node:crypto')
+
+const core = require('../../core')
+
+const db = require('./clans.db')
+const visibility = require('../visibility/visibility.model')
+
+const log = core.logger('clans')
+
+/**
+ * How long a board may go without its `t` advancing and still count as current.
+ *
+ * The plugin re-sends it every 60 seconds and this module reads it every 30, so
+ * three minutes tolerates two missed boards before a server stops vouching for
+ * its clans.
+ */
+const FRESH_MS = 3 * 60 * 1000
+
+/**
+ * How far back a clan event's feed item is offered to core again.
+ *
+ * Core writes an item only for a Team it already holds, and a clan founded a
+ * moment ago is not one yet: its Team appears on core's next reconcile, which is
+ * debounced by up to 30 seconds. So the "founded" line — the first line of every
+ * clan's feed — would always be dropped if it were offered once. It is offered
+ * on every board refresh for this long instead, and core's dedupe key makes every
+ * offer after the first that lands a no-op.
+ */
+const REOFFER_MS = 10 * 60 * 1000
+
+/** Team kinds core's `publish` takes, by the clan event that produces them. */
+const PUBLISH = Object.freeze({
+ 'clan.created': 'team.created',
+ 'clan.disbanded': 'team.disbanded',
+ 'clan.member.added': 'team.member.added',
+ 'clan.member.left': 'team.member.removed',
+ 'clan.member.kicked': 'team.member.removed',
+})
+
+/**
+ * The feed items D49 allows: membership, and nothing else. Every one is
+ * members-only. A disband is not here — it was not one of the four the org lead
+ * chose, and the Team it would be written to is about to be archived anyway.
+ */
+const ACTIVITY = Object.freeze({
+ 'clan.created': 'rust.clan.founded',
+ 'clan.member.added': 'rust.clan.joined',
+ 'clan.member.left': 'rust.clan.left',
+ 'clan.member.kicked': 'rust.clan.removed',
+})
+
+const CLAN_KINDS = Object.freeze(Object.keys(PUBLISH))
+
+const STEAM_ID = /^\d{1,32}$/
+const COLOR = /^#[0-9a-f]{6}$/i
+
+/** The Team identity (D52). */
+function externalIdOf(serverId, clanId, createdMs) {
+ return `${serverId}:${clanId}:${createdMs}`
+}
+
+const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null)
+const int = (value) => (Number.isInteger(Number(value)) && value !== null && value !== '' ? Number(value) : null)
+
+/**
+ * One board row as this module stores it, or null when it cannot be read.
+ *
+ * A member whose Steam id is not a Steam id is dropped rather than failing the
+ * clan: the roster is still true about everybody else. A clan with no id, no
+ * creation time or no name fails as a whole, because it has no identity to
+ * store it under.
+ */
+function normaliseClan(serverId, raw) {
+ if (!raw || typeof raw !== 'object') return null
+
+ const clanId = int(raw.clanId)
+ const createdMs = int(raw.createdMs)
+ const name = text(raw.name, 191)
+ if (clanId == null || createdMs == null || createdMs <= 0 || !name) return null
+
+ const members = []
+ for (const m of Array.isArray(raw.members) ? raw.members : []) {
+ const steamId = m && typeof m.steamId === 'string' && STEAM_ID.test(m.steamId) ? m.steamId : null
+ if (!steamId) continue
+ members.push({
+ steamId,
+ name: text(m.name, 191),
+ rank: int(m.rank),
+ role: text(m.role, 64),
+ joinedMs: int(m.joinedMs),
+ })
+ }
+
+ return {
+ externalId: externalIdOf(serverId, clanId, createdMs),
+ serverId,
+ clanId,
+ createdMs,
+ name,
+ color: typeof raw.color === 'string' && COLOR.test(raw.color) ? raw.color.toLowerCase() : null,
+ score: int(raw.score) || 0,
+ maxMembers: int(raw.maxMembers),
+ memberCount: members.length,
+ members,
+ }
+}
+
+/** A member signature, so an unchanged roster is not rewritten every minute. */
+const signature = (members) =>
+ members
+ .map((m) => `${m.steamId}|${m.rank == null ? '' : m.rank}|${m.role || ''}|${m.name || ''}`)
+ .sort()
+ .join('\n')
+
+const leadersOf = (members) => new Set(members.filter((m) => Number(m.rank) === 1).map((m) => m.steamId))
+
+/**
+ * Tells core something, and never lets core's answer become this module's
+ * problem. Both calls are fire-and-forget by contract; the catch is for a core
+ * that throws synchronously all the same.
+ */
+function publish(event) {
+ try {
+ Promise.resolve(core.teams.publish(event)).catch((err) => {
+ log.warn('teams publish failed', { kind: event.kind, externalId: event.externalId, error: err.message })
+ })
+ } catch (err) {
+ log.warn('teams publish threw', { kind: event.kind, externalId: event.externalId, error: err.message })
+ }
+}
+
+function requestReconcile(reason) {
+ try {
+ core.teams.reconcile({ reason })
+ } catch (err) {
+ log.warn('teams reconcile request threw', { reason, error: err.message })
+ }
+}
+
+function pushActivity(items) {
+ if (!items.length) return
+ try {
+ Promise.resolve(core.teams.pushActivity(items)).catch((err) => {
+ log.warn('teams activity push failed', { items: items.length, error: err.message })
+ })
+ } catch (err) {
+ log.warn('teams activity push threw', { items: items.length, error: err.message })
+ }
+}
+
+// ── The board ──────────────────────────────────────────────────────────────
+
+/**
+ * Applies one server's `clans` board.
+ *
+ * `board` is undefined when the sidecar holds none — a plugin older than
+ * protocol 6, or one that has not connected since it was upgraded. That is
+ * recorded as unsupported, and the clans already stored are left exactly as they
+ * are: a missing board is the absence of an answer, not an answer of absence.
+ *
+ * Returns what happened, for the log and the tests.
+ */
+async function applyBoard(serverId, board) {
+ if (!board || typeof board !== 'object') {
+ await db.putBoard({
+ serverId,
+ boardT: null,
+ advanced: false,
+ enabled: true,
+ supported: false,
+ truncated: false,
+ backend: null,
+ reason: "this server has not sent a clan board; its plugin may predate protocol 6",
+ umodClans: false,
+ clanCount: 0,
+ })
+ return { applied: false, reason: 'no board' }
+ }
+
+ const previous = await db.getBoard(serverId)
+ const boardT = Number(board.t)
+ const known = previous && previous.boardT != null ? Number(previous.boardT) : null
+ const advanced = Number.isFinite(boardT) && (known == null || boardT > known)
+
+ const supported = board.supported === true
+ const raw = supported && Array.isArray(board.clans) ? board.clans : null
+
+ const clans = []
+ let unreadable = 0
+ for (const row of raw || []) {
+ const clan = normaliseClan(serverId, row)
+ if (clan) clans.push(clan)
+ else unreadable += 1
+ }
+
+ // A row this build could not read is treated like the ceiling: the board no
+ // longer vouches for what it leaves out.
+ const truncated = board.truncated === true || unreadable > 0
+
+ await db.putBoard({
+ serverId,
+ boardT: Number.isFinite(boardT) ? boardT : null,
+ advanced,
+ enabled: board.enabled !== false,
+ supported,
+ truncated,
+ backend: text(board.backend, 64),
+ reason: supported ? null : text(board.reason, 255) || 'the plugin could not read this server\'s clans',
+ umodClans: board.umodClans === true,
+ clanCount: clans.length,
+ })
+
+ if (unreadable) log.warn('clan board carried rows this build could not read', { server: serverId, unreadable })
+
+ // A board whose `t` has not moved is the one already applied. Re-applying it
+ // would rewrite every roster every 30 seconds to say what it already says.
+ if (!advanced || !raw) return { applied: false, reason: advanced ? 'unsupported' : 'unchanged' }
+
+ const [before, beforeMembers] = await Promise.all([
+ db.listClansForServer(serverId),
+ db.listMembersForServer(serverId),
+ ])
+
+ const wasActive = new Map(before.filter((c) => !c.goneAt).map((c) => [c.externalId, c]))
+ const rosterBefore = new Map()
+ for (const m of beforeMembers) {
+ if (!rosterBefore.has(m.externalId)) rosterBefore.set(m.externalId, [])
+ rosterBefore.get(m.externalId).push(m)
+ }
+
+ let created = 0
+ let rosterChanged = 0
+ const leaderEvents = []
+
+ for (const clan of clans) {
+ // eslint-disable-next-line no-await-in-loop
+ await db.upsertClan(clan)
+
+ const old = rosterBefore.get(clan.externalId) || []
+ if (!wasActive.has(clan.externalId)) created += 1
+
+ if (signature(old) !== signature(clan.members)) {
+ // eslint-disable-next-line no-await-in-loop
+ await db.replaceMembers(clan.externalId, clan.members)
+ rosterChanged += 1
+ }
+
+ // Leadership is only ever learned here (D54): the game raises no hook when
+ // somebody is promoted. Published only for a clan that was already on the
+ // previous board — a brand-new clan's leaders reach core with the Team.
+ if (wasActive.has(clan.externalId)) {
+ const was = leadersOf(old)
+ const now = leadersOf(clan.members)
+ for (const key of now) if (!was.has(key)) leaderEvents.push({ kind: 'team.leader.added', externalId: clan.externalId, memberKey: key })
+ for (const key of was) if (!now.has(key)) leaderEvents.push({ kind: 'team.leader.removed', externalId: clan.externalId, memberKey: key })
+ }
+ }
+
+ // Only a complete board may say a clan is gone.
+ const onBoard = new Set(clans.map((c) => c.externalId))
+ const gone = truncated ? [] : [...wasActive.keys()].filter((id) => !onBoard.has(id))
+ await db.markGone(gone)
+
+ for (const event of leaderEvents) publish(event)
+
+ if (created || gone.length || rosterChanged) {
+ requestReconcile('rust clans board changed')
+ }
+
+ if (created || gone.length || rosterChanged || leaderEvents.length) {
+ log.info('clan board applied', {
+ server: serverId, clans: clans.length, created, gone: gone.length, rosterChanged,
+ leaderChanges: leaderEvents.length, truncated,
+ })
+ }
+
+ return { applied: true, clans: clans.length, created, gone: gone.length, rosterChanged, leaderChanges: leaderEvents.length }
+}
+
+// ── The events ─────────────────────────────────────────────────────────────
+
+const nameOr = (name) => name || 'A player'
+
+/** The feed line for one clan event, as core stores it verbatim. */
+function summaryOf(kind, frame) {
+ switch (kind) {
+ case 'clan.created':
+ return `${nameOr(frame.name)} founded the clan.`
+ case 'clan.member.added':
+ return `${nameOr(frame.name)} joined the clan.`
+ case 'clan.member.left':
+ return `${nameOr(frame.name)} left the clan.`
+ case 'clan.member.kicked':
+ return frame.byName
+ ? `${nameOr(frame.name)} was removed from the clan by ${frame.byName}.`
+ : `${nameOr(frame.name)} was removed from the clan.`
+ default:
+ return null
+ }
+}
+
+/**
+ * A key core can dedupe on, from the frame's own content.
+ *
+ * Content rather than this module's event row id, so that the same frame read
+ * twice — a cursor replayed after a crash, or the re-offer below — is the same
+ * item. **Hashed, because core clamps a dedupe key to 40 characters**, and a
+ * readable key long enough to be unique (server, clan, creation time, kind,
+ * player, instant) would be cut short into collisions without a word.
+ */
+function dedupeKeyOf(serverId, kind, frame) {
+ const parts = [serverId, frame.clanId, frame.createdMs, kind, frame.steamId || '', frame.t]
+ return crypto.createHash('sha1').update(parts.join('|')).digest('hex')
+}
+
+/** One clan event as a Team feed item, or null when D49 does not allow it. */
+function activityItem(serverId, externalId, kind, frame) {
+ const itemKind = ACTIVITY[kind]
+ const summary = itemKind && summaryOf(kind, frame)
+ if (!summary) return null
+
+ const t = Number(frame.t)
+ return {
+ externalId,
+ kind: itemKind,
+ summary,
+ occurredAt: Number.isFinite(t) ? t : Date.now(),
+ visibility: 'members',
+ actorMemberKey: kind === 'clan.member.kicked' ? frame.bySteamId || null : frame.steamId || null,
+ payload: { serverId, steamId: frame.steamId || null },
+ dedupeKey: dedupeKeyOf(serverId, kind, frame),
+ }
+}
+
+/** The Team identity a clan event names, or null when it cannot be worked out. */
+async function resolveExternalId(serverId, frame) {
+ const clanId = int(frame.clanId)
+ const createdMs = int(frame.createdMs)
+ if (clanId == null) return null
+ if (createdMs != null && createdMs > 0) return externalIdOf(serverId, clanId, createdMs)
+
+ // `clan.member.added` can arrive without a creation time when the plugin could
+ // not read the clan back. Matched on the game id, newest first.
+ const known = await db.findByGameId(serverId, clanId)
+ return known ? known.externalId : null
+}
+
+/**
+ * Applies one `clan.*` event: tells core, and writes the Team feed.
+ *
+ * Called from ingest, after the raw frame is stored. The board that follows
+ * every one of these (the plugin re-sends it a few seconds later) is what the
+ * store is rebuilt from; this only makes the change visible sooner and records
+ * the line for the feed.
+ */
+async function applyEvent(serverId, frame) {
+ const kind = frame && frame.kind
+ if (!PUBLISH[kind]) return { applied: false }
+
+ if (frame.steamId) await db.rememberName(frame.steamId, text(frame.name, 191))
+ if (frame.bySteamId) await db.rememberName(frame.bySteamId, text(frame.byName, 191))
+
+ const externalId = await resolveExternalId(serverId, frame)
+ if (!externalId) {
+ log.info('clan event names a clan this module has never seen', { server: serverId, kind, clanId: frame.clanId })
+ return { applied: false }
+ }
+
+ // The game said it: this clan is gone. Recorded here as well as by the next
+ // board, because a board truncated at the ceiling would never say so.
+ if (kind === 'clan.disbanded') await db.markGone([externalId])
+
+ const event = { kind: PUBLISH[kind], externalId }
+ if (event.kind.startsWith('team.member.')) {
+ if (!frame.steamId) return { applied: false }
+ event.memberKey = String(frame.steamId)
+ }
+ publish(event)
+
+ const item = activityItem(serverId, externalId, kind, frame)
+ if (item) pushActivity([item])
+
+ return { applied: true, externalId }
+}
+
+/**
+ * Offers the last few minutes of one server's clan feed items to core again.
+ *
+ * See `REOFFER_MS`. Called after each board refresh; idempotent by construction.
+ */
+async function reofferActivity(serverId, now = Date.now()) {
+ const rows = await db.recentClanEvents(serverId, now - REOFFER_MS)
+ const items = []
+
+ for (const row of rows) {
+ let frame
+ try {
+ frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw
+ } catch (err) {
+ continue
+ }
+ if (!frame || !ACTIVITY[frame.kind]) continue
+
+ // eslint-disable-next-line no-await-in-loop
+ const externalId = await resolveExternalId(serverId, frame)
+ const item = externalId && activityItem(serverId, externalId, frame.kind, frame)
+ if (item) items.push(item)
+ }
+
+ pushActivity(items)
+ return items.length
+}
+
+// ── Who may see a roster (D48) ─────────────────────────────────────────────
+
+/**
+ * May this viewer see this clan's roster?
+ *
+ * `viewer` is `{ userId, role }` or null — the shape core hands `projectRoster`,
+ * so core's roster and this module's page decide it with one function.
+ *
+ * The viewer's standing is re-read from the `users` row, never taken from what
+ * the caller says, for the same reason the presence gate does it: a moderator
+ * demoted this morning, or an account banned, must lose the roster on the next
+ * request. Everything that cannot be answered answers no.
+ */
+async function canSeeRoster(viewer, externalId) {
+ const audience = await visibility.clanRosterAudience()
+ if (audience === 'public') return true
+ if (!viewer || viewer.userId == null) return false
+
+ const user = await core.users.getById(viewer.userId)
+ if (!user || (user.status && user.status !== 'active')) return false
+
+ if (audience === 'signed_in') return true
+ if (user.role === 'admin' || user.role === 'moderator') return true
+ return db.userIsMember(externalId, user.id)
+}
+
+// ── The public reads ───────────────────────────────────────────────────────
+
+const shapeBoard = (board, now = Date.now()) => {
+ if (!board || board.supported == null) {
+ return { supported: false, fresh: false, truncated: false, enabled: true, reason: 'this server has not sent a clan board yet' }
+ }
+ const seenAt = board.seenAt ? new Date(board.seenAt).getTime() : null
+ return {
+ supported: Boolean(board.supported),
+ enabled: Boolean(board.enabled),
+ truncated: Boolean(board.truncated),
+ fresh: Boolean(board.supported) && seenAt != null && now - seenAt < FRESH_MS,
+ reason: board.reason || null,
+ }
+}
+
+/** The Clans tab (D58): every clan on one server's board, best first. Public. */
+async function listForServer(serverId, now = Date.now()) {
+ const [clans, board] = await Promise.all([db.listPublicForServer(serverId), db.getBoard(serverId)])
+ return {
+ clans: clans.map((c) => ({
+ externalId: c.externalId,
+ name: c.name,
+ color: c.color || null,
+ score: Number(c.score) || 0,
+ memberCount: Number(c.memberCount) || 0,
+ maxMembers: c.maxMembers == null ? null : Number(c.maxMembers),
+ })),
+ board: shapeBoard(board, now),
+ }
+}
+
+/**
+ * One clan, and its roster if the viewer may see it.
+ *
+ * The roster carries no Steam id and no website account id — the same two fields
+ * core withholds from every public roster. `online` is inside the audience by
+ * construction (D48): a viewer who may not see the roster sees no names at all.
+ */
+async function getForViewer(externalId, viewer) {
+ const clan = await db.findClan(externalId)
+ if (!clan) return null
+
+ const allowed = await canSeeRoster(viewer, externalId)
+ const audience = await visibility.clanRosterAudience()
+
+ const members = allowed && !clan.goneAt ? await db.listMembers(externalId) : []
+
+ return {
+ clan: {
+ externalId: clan.externalId,
+ name: clan.name,
+ color: clan.color || null,
+ score: Number(clan.score) || 0,
+ memberCount: Number(clan.memberCount) || 0,
+ maxMembers: clan.maxMembers == null ? null : Number(clan.maxMembers),
+ serverId: clan.serverId,
+ serverName: clan.serverName,
+ founded: Number(clan.createdMs) || null,
+ gone: Boolean(clan.goneAt),
+ },
+ roster: {
+ visible: allowed,
+ audience,
+ members: members.map((m) => ({
+ name: m.name || null,
+ role: m.role || null,
+ leader: Number(m.rank) === 1,
+ online: Boolean(Number(m.online)),
+ joined: m.joinedMs == null ? null : Number(m.joinedMs),
+ })),
+ },
+ }
+}
+
+/**
+ * Every configured server's clan board as the admin page shows it: whether it is
+ * current, whether it is at the ceiling (D55), why it cannot be read, and
+ * whether the uMod Clans plugin is loaded there (D47) — whose clans are a
+ * separate system and never Teams.
+ */
+async function boardsForAdmin(now = Date.now()) {
+ const rows = await db.listBoards()
+ return rows.map((row) => ({
+ id: row.serverId,
+ name: row.serverName,
+ ...shapeBoard(row.supported == null ? null : row, now),
+ clans: Number(row.clanCount) || 0,
+ umodClans: Boolean(row.umodClans),
+ }))
+}
+
+module.exports = {
+ FRESH_MS,
+ boardsForAdmin,
+ REOFFER_MS,
+ CLAN_KINDS,
+ externalIdOf,
+ normaliseClan,
+ applyBoard,
+ applyEvent,
+ reofferActivity,
+ activityItem,
+ dedupeKeyOf,
+ canSeeRoster,
+ shapeBoard,
+ listForServer,
+ getForViewer,
+}
diff --git a/server/model/clans/teamProvider.js b/server/model/clans/teamProvider.js
new file mode 100644
index 0000000..b573126
--- /dev/null
+++ b/server/model/clans/teamProvider.js
@@ -0,0 +1,202 @@
+// ── module-rust's Team provider ────────────────────────────────────────────
+//
+// The questions core asks this module about Teams (MODULE_API.md
+// `api.registerTeamProvider`, TEAMS.md §2.3). A first-party Rust clan is a Team
+// (R5); this file is the whole of the translation, and `model/clans` is where
+// the clans themselves are kept.
+//
+// ── The envelope is the contract ──────────────────────────────────────────
+//
+// Every method answers `{ ok, ... }` and `{ ok: false, reason }` is an ordinary
+// answer. Core reads it as "keep what you have" — staleness, never emptiness —
+// and there is no shape a failure can take that core reads as "zero Teams". An
+// empty array is the one thing this file must never say while it does not know.
+//
+// ── Many servers, one answer (D53) ─────────────────────────────────────────
+//
+// `module-uo` has one shard and one socket, so "is the board current" has one
+// answer. This module has a fleet, and the answer is per server. `getTeams` is
+// therefore:
+//
+// • `complete: true` only when EVERY configured server's board is fresh,
+// supported and untruncated — then core may archive a
+// Team that is missing;
+// • `complete: false` when at least one is current and some are not — core
+// adds and updates, and removes nothing. One server being
+// off for a patch must never archive its clans;
+// • a refusal when none is current.
+//
+// A clan is only ever as current as its own server's board, so the roster
+// methods ask about that server alone.
+
+const core = require('../../core')
+
+const db = require('./clans.db')
+const clans = require('./clans.model')
+const servers = require('../servers/servers.model')
+
+const log = core.logger('teams')
+
+const refuse = (reason) => ({ ok: false, reason })
+
+/** Is this board record current? The rule `model/clans` states, applied to one row. */
+function isFresh(board, now = Date.now()) {
+ return clans.shapeBoard(board, now).fresh
+}
+
+/**
+ * `getTeams()` — every clan on every server's board.
+ *
+ * `meta` carries the server and the clan's colour and score, opaquely: core
+ * stores and shows it and never branches on it.
+ */
+async function getTeams(now = Date.now()) {
+ try {
+ const configured = await servers.listForPolling()
+ if (!configured.length) return refuse('no Rust servers are configured')
+
+ const boards = await db.listBoards()
+ const byServer = new Map(boards.map((b) => [b.serverId, b]))
+
+ const fresh = []
+ const behind = []
+ for (const server of configured) {
+ const board = byServer.get(server.id)
+ if (isFresh(board, now)) fresh.push(server.id)
+ else behind.push(server.id)
+ }
+
+ if (!fresh.length) {
+ return refuse(`no server has sent a current clan board (${behind.join(', ')})`)
+ }
+
+ // Complete only when nothing is behind, and nothing is at the ceiling. A
+ // server that is configured but switched off in this module is "behind" by
+ // construction — its board is never read — which is the conservative answer:
+ // switching a server off is not a statement that its clans are gone.
+ const truncated = fresh.filter((id) => byServer.get(id).truncated)
+ const complete = behind.length === 0 && truncated.length === 0
+
+ const rows = await db.listActiveClans()
+ const known = new Set(configured.map((s) => s.id))
+
+ return {
+ ok: true,
+ complete,
+ teams: rows
+ .filter((row) => known.has(row.serverId))
+ .map((row) => ({
+ externalId: row.externalId,
+ name: row.name,
+ abbr: null,
+ meta: {
+ server: row.serverName || row.serverId,
+ serverId: row.serverId,
+ color: row.color || null,
+ score: Number(row.score) || 0,
+ },
+ })),
+ }
+ } catch (err) {
+ log.warn('getTeams failed', { error: err.message })
+ return refuse(`clans unreadable: ${err.message}`)
+ }
+}
+
+/** A clan and whether its server's board vouches for it right now, or a refusal. */
+async function currentClan(externalId, now) {
+ const clan = await db.findClan(externalId)
+ if (!clan) return { refusal: refuse(`clan ${externalId} is not on any board`) }
+ if (clan.goneAt) return { refusal: refuse(`clan ${externalId} has left its server's board`) }
+
+ const board = await db.getBoard(clan.serverId)
+ if (!isFresh(board, now)) {
+ return { refusal: refuse(`server ${clan.serverId} has not sent a current clan board`) }
+ }
+ return { clan }
+}
+
+/**
+ * `getTeamMembers(externalId)` — one clan's roster.
+ *
+ * **A clan with no roster rows is refused, not reported empty**, unless the board
+ * said it has none. A clan always has at least its leader, so an empty roster
+ * beside a non-zero count is a read that happened between two writes, and
+ * reporting it would tell core every member left.
+ */
+async function getTeamMembers(externalId, now = Date.now()) {
+ try {
+ const { clan, refusal } = await currentClan(externalId, now)
+ if (refusal) return refusal
+
+ const rows = await db.listMembers(externalId)
+ if (!rows.length && Number(clan.memberCount) > 0) {
+ return refuse(`roster for clan ${externalId} is not stored yet (board says ${clan.memberCount} members)`)
+ }
+
+ return {
+ ok: true,
+ complete: true,
+ members: rows.map((row) => ({
+ memberKey: row.steamId,
+ displayName: row.name || null,
+ rankLabel: row.role || null,
+ // Rank 1 is leader and several may hold it. A NULL rank — a role id the
+ // board could not match — is not a leader: "not known" must never read
+ // as "leads this clan".
+ leader: Number(row.rank) === 1,
+ online: Boolean(Number(row.online)),
+ userId: Number.isInteger(Number(row.userId)) && Number(row.userId) > 0 ? Number(row.userId) : null,
+ })),
+ }
+ } catch (err) {
+ log.warn('getTeamMembers failed', { externalId, error: err.message })
+ return refuse(`roster unreadable: ${err.message}`)
+ }
+}
+
+/** `getTeamLeaders(externalId)` — everyone at rank 1, which may be several. */
+async function getTeamLeaders(externalId, now = Date.now()) {
+ try {
+ const { refusal } = await currentClan(externalId, now)
+ if (refusal) return refusal
+
+ const rows = await db.listMembers(externalId)
+ return { ok: true, leaders: rows.filter((row) => Number(row.rank) === 1).map((row) => row.steamId) }
+ } catch (err) {
+ log.warn('getTeamLeaders failed', { externalId, error: err.message })
+ return refuse(`leadership unreadable: ${err.message}`)
+ }
+}
+
+/**
+ * Which roster rows a viewer may see (D48, MODULE_API 1.6.0).
+ *
+ * The one provider method core calls on a REQUEST path, and the one that fails
+ * CLOSED: core serves an empty roster when this refuses, because for a
+ * visibility question "keep what you have" would mean publishing the roster to
+ * whoever asked. So every path that cannot reach a confident answer refuses.
+ *
+ * All or nothing, and that is the model rather than a shortcut: the audience is
+ * a property of the ROSTER, not of a member. There is no setting in which some
+ * of a clan's members are visible and others are not.
+ */
+async function projectRoster(externalId, members, viewer) {
+ try {
+ const allowed = await clans.canSeeRoster(viewer, externalId)
+ if (!allowed) return { ok: true, members: [] }
+ return { ok: true, members: (members || []).map((m) => m.member_key).filter(Boolean) }
+ } catch (err) {
+ log.warn('projectRoster could not resolve the audience; withholding the roster', {
+ externalId, error: err.message,
+ })
+ return refuse(`the roster audience could not be resolved: ${err.message}`)
+ }
+}
+
+// Where core should point a link at a clan (MODULE_API 1.6.0, TEAMS.md §6.4).
+// Core substitutes `{externalId}` and nothing else, which is why the page is not
+// nested under its server (D56): the server is inside the id already.
+const pageUrlTemplate = '/rust/clans/{externalId}'
+
+module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, pageUrlTemplate, isFresh }
diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js
index 3d76b8b..6359fe9 100644
--- a/server/model/links/links.model.js
+++ b/server/model/links/links.model.js
@@ -23,6 +23,24 @@ const sidecar = require('../../sidecarClient')
const log = core.logger('links')
+/**
+ * A link changed, so a clan member's website account changed (D57).
+ *
+ * Core resolves a Team member's `userId` from the provider's answer, and that
+ * answer comes from this table. Without asking, a member who links today is not
+ * a member of their clan's Team on the site until core's next scheduled sweep —
+ * fifteen minutes by default — which is exactly when a player tries the clan
+ * forum for the first time. A request, not a wait: it returns at once and never
+ * throws into the link flow.
+ */
+function linksChanged(reason) {
+ try {
+ core.teams.reconcile({ reason })
+ } catch (err) {
+ log.warn('could not ask core to reconcile Teams after a link change', { reason, error: err.message })
+ }
+}
+
/** What a link looks like to any caller. Never carries a raw code. */
function shape(row) {
if (!row) return null
@@ -131,6 +149,7 @@ async function confirmOne({ server, code, userId }) {
const link = shape(await db.getBySteamId(steamId))
log.info('steam account linked', { steamId, userId, server: server.id })
+ linksChanged('rust account linked')
return { ok: true, link }
}
@@ -184,7 +203,9 @@ async function redeem({ code, userId }) {
/** 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
+ const removed = (await db.removeOwned(steamId, userId)) > 0
+ if (removed) linksChanged('rust account unlinked')
+ return removed
}
/**
@@ -200,7 +221,9 @@ async function unlinkOwned(steamId, userId) {
* and the game one has no operator to name.
*/
async function unlinkAnyOwner(steamId) {
- return (await db.removeBySteamId(steamId)) > 0
+ const removed = (await db.removeBySteamId(steamId)) > 0
+ if (removed) linksChanged('rust account unlinked')
+ return removed
}
/**
diff --git a/server/model/visibility/visibility.model.js b/server/model/visibility/visibility.model.js
index fc99a64..d796a84 100644
--- a/server/model/visibility/visibility.model.js
+++ b/server/model/visibility/visibility.model.js
@@ -47,6 +47,33 @@ const PRESENCE_KEY = 'presence.audience'
const isAudience = (value) => RANK.has(value)
+// ── Who may see a clan's roster (phase 9, D48) ────────────────────────────
+//
+// The same rule applied to a roster: a roster says who is in a clan and, inside
+// its audience, which of them is on. So it defaults to the clan's OWN members
+// plus staff, and an operator widens it deliberately.
+//
+// members staff, and a website account linked to one of the clan's members
+// signed_in any active website account
+// public anybody
+//
+// One fleet-wide setting (D48), deliberately without a per-server override: the
+// presence override exists because a PvE server may publish a roll call a PvP one
+// must not, and a roster is the same answer on every server of the fleet.
+//
+// **Widening it widens online status too.** Core's `projectRoster` can withhold a
+// roster's rows but not its fields, so there is no rung that shows who is in a
+// clan and hides which of them is on. The admin page says so beside the switch.
+const CLAN_AUDIENCES = Object.freeze(['public', 'signed_in', 'members'])
+
+/** The narrowest rung, and the default until an operator chooses. */
+const DEFAULT_CLAN_ROSTER = 'members'
+
+/** The `rust_settings` key the roster audience lives under. */
+const CLAN_ROSTER_KEY = 'clans.roster.audience'
+
+const isClanAudience = (value) => CLAN_AUDIENCES.includes(value)
+
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
@@ -124,11 +151,26 @@ async function canSeePresence(req, serverId) {
}
}
+/**
+ * The clan roster audience. An unrecognised stored word narrows to `members`,
+ * and a read that fails throws — every caller answers "no" on a throw, which is
+ * the direction a roster must fail in.
+ */
+async function clanRosterAudience() {
+ const stored = await db.getSetting(CLAN_ROSTER_KEY)
+ return isClanAudience(stored) ? stored : DEFAULT_CLAN_ROSTER
+}
+
/** The admin screen's read: the fleet default and every server beside it. */
async function describe() {
- const [fleet, servers] = await Promise.all([fleetPresence(), db.listServerPresence()])
+ const [fleet, servers, clanRoster] = await Promise.all([
+ fleetPresence(),
+ db.listServerPresence(),
+ clanRosterAudience(),
+ ])
return {
audiences: [...AUDIENCES],
+ clans: { audiences: [...CLAN_AUDIENCES], roster: clanRoster },
presence: {
fleet,
servers: servers.map((s) => {
@@ -155,11 +197,19 @@ async function describe() {
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
-async function update({ fleet, servers } = {}, actor = null) {
+async function update({ fleet, servers, clanRoster } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
+ if (clanRoster !== undefined && !isClanAudience(clanRoster)) {
+ return {
+ ok: false,
+ status: 400,
+ message: `"${clanRoster}" is not a clan roster audience. Choose one of: ${CLAN_AUDIENCES.join(', ')}.`,
+ }
+ }
+
const changes = Object.entries(servers || {})
for (const [id, value] of changes) {
if (value !== null && !isAudience(value)) {
@@ -174,6 +224,7 @@ async function update({ fleet, servers } = {}, actor = null) {
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
+ if (clanRoster !== undefined) await db.setSetting(CLAN_ROSTER_KEY, clanRoster, userId)
for (const [id, value] of changes) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
@@ -186,6 +237,7 @@ async function update({ fleet, servers } = {}, actor = null) {
ok: true,
changed: {
...(fleet !== undefined ? { fleet } : {}),
+ ...(clanRoster !== undefined ? { clanRoster } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
},
}
@@ -196,6 +248,11 @@ module.exports = {
DEFAULT_PRESENCE,
PRESENCE_KEY,
isAudience,
+ CLAN_AUDIENCES,
+ DEFAULT_CLAN_ROSTER,
+ CLAN_ROSTER_KEY,
+ isClanAudience,
+ clanRosterAudience,
meets,
normalise,
viewerLevel,
diff --git a/server/router/admin/visibility.controller.js b/server/router/admin/visibility.controller.js
index 1c4b7b2..0bab4bf 100644
--- a/server/router/admin/visibility.controller.js
+++ b/server/router/admin/visibility.controller.js
@@ -2,13 +2,25 @@
const core = require('../../core')
+const clans = require('../../model/clans/clans.model')
const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('visibility')
+/**
+ * The page's whole state: both settings, and each server's clan board beside
+ * the roster setting. The board is where "this server runs the uMod Clans
+ * plugin, whose clans are not Teams" (D47) and "this server is at the game's
+ * 100-clan ceiling" (D55) come from.
+ */
+async function describe() {
+ const [settings, boards] = await Promise.all([visibility.describe(), clans.boardsForAdmin()])
+ return { ...settings, clans: { ...settings.clans, servers: boards } }
+}
+
async function read(req, res) {
try {
- res.json(await visibility.describe())
+ res.json(await describe())
} catch (err) {
log.error('failed to read visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to read the visibility settings' })
@@ -17,8 +29,8 @@ async function read(req, res) {
async function update(req, res) {
try {
- const { fleet, servers } = req.body || {}
- const result = await visibility.update({ fleet, servers }, req.user)
+ const { fleet, servers, clanRoster } = req.body || {}
+ const result = await visibility.update({ fleet, servers, clanRoster }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
@@ -29,7 +41,7 @@ async function update(req, res) {
// person and a time.
await core.activity.log({ req, action: 'rust.visibility.save', detail: result.changed })
- res.json(await visibility.describe())
+ res.json(await describe())
} catch (err) {
log.error('failed to save visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to save the visibility settings' })
diff --git a/server/router/admin/visibility.router.js b/server/router/admin/visibility.router.js
index 03892bb..92e7f04 100644
--- a/server/router/admin/visibility.router.js
+++ b/server/router/admin/visibility.router.js
@@ -21,12 +21,13 @@ const { body } = core.validator
const visibilityRouter = express.Router()
const AUDIENCES = ['staff', 'signed_in', 'public']
+const CLAN_AUDIENCES = ['members', 'signed_in', 'public']
visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
- // #swagger.summary = 'Who may see who is online'
- // #swagger.description = 'The fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.'
+ // #swagger.summary = 'Who may see who is online, and who may see a clan roster'
+ // #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
@@ -35,8 +36,8 @@ visibilityRouter.get(
visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
- // #swagger.summary = 'Change who may see who is online'
- // #swagger.description = 'Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.'
+ // #swagger.summary = 'Change who may see who is online, or who may see a clan roster'
+ // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
@@ -44,6 +45,7 @@ visibilityRouter.put(
requireRole('admin'),
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
+ body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
validate,
visibility.update,
)
diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js
index ce05d14..6be2c0c 100644
--- a/server/router/public/rust.controller.js
+++ b/server/router/public/rust.controller.js
@@ -11,6 +11,7 @@
const core = require('../../core')
+const clans = require('../../model/clans/clans.model')
const events = require('../../model/events/events.model')
const servers = require('../../model/servers/servers.model')
const visibility = require('../../model/visibility/visibility.model')
@@ -160,4 +161,59 @@ async function listOnline(req, res) {
}
}
-module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline }
+/**
+ * Who is asking, as core describes a viewer to `projectRoster`: `{ userId, role }`
+ * or null. Only the id is trusted — `model/clans` re-reads the row — so a token
+ * that cannot be decoded is simply nobody.
+ */
+function viewerOf(req) {
+ try {
+ const claimed = req.user || core.auth.getUserFromRequest(req)
+ if (!claimed || claimed.id == null) return null
+ return { userId: claimed.id, role: claimed.role || null }
+ } catch (err) {
+ return null
+ }
+}
+
+/**
+ * One server's clans (D58): name, colour, score and member count, best first.
+ *
+ * Public at every setting, because none of it names a player. `board` says
+ * whether the list can be trusted — a server whose plugin predates protocol 6,
+ * or whose clans the bridge cannot read, answers an empty list AND the reason,
+ * so the tab can say "unavailable" rather than "no clans".
+ */
+async function listClans(req, res) {
+ try {
+ res.json(await clans.listForServer(req.params.id))
+ } catch (err) {
+ log.error('failed to read clans', { server: req.params.id, error: err.message })
+ res.status(500).json({ message: 'Failed to read clans' })
+ }
+}
+
+/**
+ * One clan, and its roster when the viewer is inside the roster audience (D48).
+ *
+ * The same decision core's `projectRoster` makes, from the same function, so
+ * this page and core's roster cannot disagree about who may look. Below the
+ * audience the clan is still described — its name and its count are public —
+ * and `roster.visible` is false with no names at all.
+ */
+async function getClan(req, res) {
+ try {
+ const answer = await clans.getForViewer(req.params.externalId, viewerOf(req))
+ perViewer(res)
+ if (!answer) {
+ res.status(404).json({ message: 'No such clan' })
+ return
+ }
+ res.json(answer)
+ } catch (err) {
+ log.error('failed to read a clan', { clan: req.params.externalId, error: err.message })
+ res.status(500).json({ message: 'Failed to read the clan' })
+ }
+}
+
+module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline, listClans, getClan }
diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js
index 60116c2..46b9416 100644
--- a/server/router/public/rust.router.js
+++ b/server/router/public/rust.router.js
@@ -114,4 +114,32 @@ rustRouter.get(
servers.listOnline,
)
+// ── Clans (phase 9) ───────────────────────────────────────────────────────
+//
+// Rust's own clans, which this module also answers core's Team questions from.
+// The list is public (D58); a roster is not (D48).
+
+rustRouter.get(
+ '/servers/:id/clans',
+ // #swagger.tags = ['Public · Rust']
+ // #swagger.summary = 'The clans on one Rust server'
+ // #swagger.description = 'Every clan on the server’s clan board, best score first: name, colour, score and member count. Public, because none of it names a player. `board` says whether the list is current and complete — a server whose bridge cannot read its clans answers an empty list and the reason, and a server at the game’s 100-clan ceiling says `truncated`.'
+ // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
+ /* #swagger.responses[200] = { description: 'The clans', content: { "application/json": { schema: { $ref: "#/components/schemas/RustClanList" } } } } */
+ siteMode,
+ servers.listClans,
+)
+
+rustRouter.get(
+ '/clans/:externalId',
+ // #swagger.tags = ['Public · Rust']
+ // #swagger.summary = 'One Rust clan'
+ // #swagger.description = 'A clan and, when the viewer is inside the operator’s clan roster audience, its roster. The audience defaults to the clan’s own members (a website account linked to one of them) and staff. Below it the clan is still described and `roster.visible` is false with no names. The roster never carries a Steam id or a website account id. `externalId` is `::`, the same identity the site’s Team pages use.'
+ // #swagger.parameters['externalId'] = { in: 'path', required: true, description: 'The clan’s identity: server, clan id and creation time in epoch ms, joined by colons', schema: { type: 'string' } }
+ /* #swagger.responses[200] = { description: 'The clan', content: { "application/json": { schema: { $ref: "#/components/schemas/RustClan" } } } } */
+ /* #swagger.responses[404] = { description: 'No such clan' } */
+ siteMode,
+ servers.getClan,
+)
+
module.exports = rustRouter
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index d5b7664..7012dbf 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -52,11 +52,11 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
- * **5 — configuration from the site.** Protocol 2 was the read path, 3 the
- * first message the WEBSITE originates (`link.confirm`), 4 the first that
- * writes to the game's permission store; 5 is the first that writes to the game
- * HOST'S FILESYSTEM — a plugin's settings, and a reload watched closely enough
- * to be undone. The bump lands here in the same change as the emitters,
+ * **6 — first-party clans.** Protocol 2 was the read path, 3 the first
+ * message the WEBSITE originates (`link.confirm`), 4 the first that writes to
+ * the game's permission store, 5 the first that writes to the game HOST'S
+ * FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
+ * built from, and no route at all. 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
@@ -66,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 = 5
+const PROTOCOL_VERSION = 6
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index a87da18..78c630a 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -535,6 +535,110 @@ module.exports = {
},
},
},
+ clans: {
+ type: 'object',
+ description: 'Who may see a clan roster, and each server’s clan board.',
+ properties: {
+ audiences: { type: 'array', items: { $ref: '#/components/schemas/RustClanAudience' } },
+ roster: { $ref: '#/components/schemas/RustClanAudience' },
+ servers: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', example: 'main' },
+ name: { type: 'string', example: 'Main · Vanilla' },
+ supported: { type: 'boolean', example: true },
+ enabled: { type: 'boolean', example: true },
+ fresh: { type: 'boolean', example: true },
+ truncated: { type: 'boolean', example: false },
+ reason: { type: 'string', nullable: true },
+ clans: { type: 'integer', example: 14 },
+ umodClans: { type: 'boolean', description: 'Is the uMod Clans plugin loaded? Its clans are a separate system and are not Teams.', example: false },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ RustClanAudience: {
+ type: 'string',
+ enum: ['members', 'signed_in', 'public'],
+ description: 'Who may see a clan’s roster: the clan’s own members (a website account linked to one of them) and staff, any signed-in account, or anybody. Widening it also shows which members are online to that audience.',
+ example: 'members',
+ },
+ RustClanBoard: {
+ type: 'object',
+ description: 'Whether a server’s clan list can be trusted right now.',
+ properties: {
+ supported: { type: 'boolean', description: 'Could the bridge read this server’s clans at all?', example: true },
+ enabled: { type: 'boolean', description: 'Is the game’s clan system switched on?', example: true },
+ fresh: { type: 'boolean', description: 'Has the board been re-sent within the last three minutes?', example: true },
+ truncated: { type: 'boolean', description: 'At the game’s 100-clan ceiling, or too large for one line: there may be clans the list does not show.', example: false },
+ reason: { type: 'string', nullable: true, description: 'Why the clans cannot be read, when they cannot.' },
+ },
+ },
+ RustClanList: {
+ type: 'object',
+ description: 'One server’s clans (GET /public/rust/servers/{id}/clans). Public: nothing here names a player.',
+ properties: {
+ clans: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ externalId: { type: 'string', example: 'main:12:1790142840535' },
+ name: { type: 'string', example: 'Northwatch' },
+ color: { type: 'string', nullable: true, example: '#3fa9f5' },
+ score: { type: 'integer', example: 140 },
+ memberCount: { type: 'integer', example: 6 },
+ maxMembers: { type: 'integer', nullable: true, example: 100 },
+ },
+ },
+ },
+ board: { $ref: '#/components/schemas/RustClanBoard' },
+ },
+ },
+ RustClan: {
+ type: 'object',
+ description: 'One clan (GET /public/rust/clans/{externalId}) and, inside the roster audience, its roster.',
+ properties: {
+ clan: {
+ type: 'object',
+ properties: {
+ externalId: { type: 'string', example: 'main:12:1790142840535' },
+ name: { type: 'string', example: 'Northwatch' },
+ color: { type: 'string', nullable: true, example: '#3fa9f5' },
+ score: { type: 'integer', example: 140 },
+ memberCount: { type: 'integer', example: 6 },
+ maxMembers: { type: 'integer', nullable: true, example: 100 },
+ serverId: { type: 'string', example: 'main' },
+ serverName: { type: 'string', example: 'Main · Vanilla' },
+ founded: { type: 'integer', nullable: true, description: 'When the clan was founded, epoch milliseconds.' },
+ gone: { type: 'boolean', description: 'The clan has been disbanded, or has left its server’s board.', example: false },
+ },
+ },
+ roster: {
+ type: 'object',
+ properties: {
+ visible: { type: 'boolean', description: 'Is this viewer inside the roster audience? When false, `members` is empty.', example: false },
+ audience: { $ref: '#/components/schemas/RustClanAudience' },
+ members: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', nullable: true, example: 'Wanderer' },
+ role: { type: 'string', nullable: true, example: 'Leader' },
+ leader: { type: 'boolean', example: true },
+ online: { type: 'boolean', example: false },
+ joined: { type: 'integer', nullable: true, description: 'Epoch milliseconds.' },
+ },
+ },
+ },
+ },
+ },
},
},
RustVisibilityUpdate: {
@@ -547,6 +651,7 @@ module.exports = {
additionalProperties: { type: 'string', nullable: true, enum: ['staff', 'signed_in', 'public', null] },
example: { main: 'public', pvp: null },
},
+ clanRoster: { $ref: '#/components/schemas/RustClanAudience' },
},
},
RustSidecarProbe: {
diff --git a/server/test/_fakes.js b/server/test/_fakes.js
index 31ef317..b0549ea 100644
--- a/server/test/_fakes.js
+++ b/server/test/_fakes.js
@@ -65,6 +65,16 @@ function fakeCtx(overrides = {}) {
// the envelope. Only the module knows when the game restarted, so only the
// module can ask for the sweep.
events: { emit: spy(undefined), reconcile: spy(undefined) },
+ // Teams (§2.3, 1.6.0). Push only — there is no reader, because a module
+ // ANSWERS questions about Teams rather than asking them. `publish` and
+ // `activity.push` resolve like core's; `reconcile` returns nothing, because
+ // core's returns at once and a fake that returned a promise would invite a
+ // module to wait on a sweep it does not own.
+ teams: {
+ publish: spy(Promise.resolve()),
+ reconcile: spy(undefined),
+ activity: { push: spy(Promise.resolve(0)) },
+ },
// A REVERSIBLE fake, not a recording one. Core's box is AES-256-GCM keyed by
// the deployment's SECRET_ENC_KEY; what a test needs from it is that
// `decrypt(encrypt(x)) === x`, because the bug this module could have is a
diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js
index b08995a..64b9e12 100644
--- a/server/test/catalogue.test.js
+++ b/server/test/catalogue.test.js
@@ -95,7 +95,7 @@ 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 4 defines', () => {
+test('the classification covers exactly the kinds the protocol defines, through protocol 6', () => {
// 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.
@@ -120,9 +120,20 @@ test('the classification covers exactly the kinds protocol 4 defines', () => {
'account.link.requested',
'account.unlinked',
'perm.drift',
+ // Protocol 6 (§12). Clan membership is members-only (D49), so every one of
+ // these is staff-class here and reaches members through core's Team feed.
+ 'clan.created',
+ 'clan.disbanded',
+ 'clan.member.added',
+ 'clan.member.left',
+ 'clan.member.kicked',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
+
+ for (const kind of PROTOCOL_4.filter((k) => k.startsWith('clan.'))) {
+ assert.equal(catalogue.isPublic(kind), false, `${kind} is members-only and must not be public`)
+ }
})
test('every kind that names a player who was on is behind the presence setting', () => {
diff --git a/server/test/clans.test.js b/server/test/clans.test.js
new file mode 100644
index 0000000..f2f2ecc
--- /dev/null
+++ b/server/test/clans.test.js
@@ -0,0 +1,552 @@
+// ── First-party clans → core's Teams (phase 9) ─────────────────────────────
+//
+// The properties this suite holds, each with a failure behind it:
+//
+// • a clan's identity carries its creation time (D52), so a reset clan
+// database cannot hand an old clan's Team to a new one;
+// • only a COMPLETE board may say a clan is gone — a board at the game's
+// 100-clan ceiling (D55), or one with an unreadable row, proves nothing
+// about what it leaves out;
+// • leadership is learned from the board, diffed (D54);
+// • `getTeams` is complete only when EVERY server vouches (D53), and refuses
+// rather than answering empty when none does;
+// • a roster is shown to the clan's own members and staff by default (D48),
+// re-read from the users row, and a failure withholds it;
+// • every feed item is members-only (D49) and carries a dedupe key core will
+// not truncate into a collision.
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx, spy } = require('./_fakes')
+
+const SERVER = 'main'
+const T0 = 1790142840000
+
+function member(steamId, rank = 2, extra = {}) {
+ return { steamId, rank, role: rank === 1 ? 'Leader' : 'Member', joinedMs: T0, name: `P${steamId.slice(-2)}`, ...extra }
+}
+
+function clanRow(clanId, createdMs, members, extra = {}) {
+ return { clanId, createdMs, name: `Clan ${clanId}`, color: '#3FA9F5', score: 10, maxMembers: 100, members, ...extra }
+}
+
+/**
+ * The model and provider over an in-memory store, with a chosen viewer row.
+ *
+ * The store is small enough to reason about: clans and members by external id,
+ * and one board record per server. Every `clans.db` function the code under test
+ * calls is replaced; anything else it reached for would throw on the fake ctx.
+ */
+function setup({ users = {}, rosterSetting = null, servers = [{ id: SERVER }] } = {}) {
+ require('../core')._reset()
+ const ctx = fakeCtx({
+ users: { getById: async (id) => users[id] || null },
+ })
+ require('../core').init(ctx)
+
+ const db = require('../model/clans/clans.db')
+ const visibilityDb = require('../model/visibility/visibility.db')
+ const serversModel = require('../model/servers/servers.model')
+
+ const store = { clans: new Map(), members: new Map(), boards: new Map(), links: new Map(), online: new Set(), names: [] }
+ const originals = { db: { ...db }, visibilityDb: { ...visibilityDb }, servers: { ...serversModel } }
+
+ db.getBoard = async (serverId) => store.boards.get(serverId) || null
+ db.listBoards = async () =>
+ servers.map((s) => ({ serverId: s.id, serverName: s.id.toUpperCase(), ...(store.boards.get(s.id) || {}) }))
+ db.putBoard = async (b) => {
+ const prev = store.boards.get(b.serverId) || {}
+ store.boards.set(b.serverId, {
+ serverId: b.serverId,
+ boardT: b.boardT,
+ seenAt: b.advanced ? new Date() : prev.seenAt || null,
+ enabled: b.enabled ? 1 : 0,
+ supported: b.supported ? 1 : 0,
+ truncated: b.truncated ? 1 : 0,
+ backend: b.backend,
+ reason: b.reason,
+ umodClans: b.umodClans ? 1 : 0,
+ clanCount: b.clanCount,
+ })
+ }
+ db.listClansForServer = async (serverId) => [...store.clans.values()].filter((c) => c.serverId === serverId)
+ db.listMembersForServer = async (serverId) => {
+ const out = []
+ for (const c of store.clans.values()) {
+ if (c.serverId !== serverId || c.goneAt) continue
+ for (const m of store.members.get(c.externalId) || []) out.push({ externalId: c.externalId, ...m })
+ }
+ return out
+ }
+ db.upsertClan = async (c) => {
+ const prev = store.clans.get(c.externalId)
+ store.clans.set(c.externalId, { ...prev, ...c, members: undefined, goneAt: null })
+ }
+ db.replaceMembers = spy(async (externalId, members) => {
+ store.members.set(externalId, members.map((m) => ({ ...m })))
+ })
+ db.markGone = async (ids) => {
+ for (const id of ids) {
+ const c = store.clans.get(id)
+ if (c && !c.goneAt) c.goneAt = new Date()
+ store.members.delete(id)
+ }
+ }
+ db.findClan = async (id) => {
+ const c = store.clans.get(id)
+ return c ? { ...c, serverName: c.serverId.toUpperCase() } : null
+ }
+ db.findByGameId = async (serverId, clanId) => {
+ const hits = [...store.clans.values()]
+ .filter((c) => c.serverId === serverId && c.clanId === clanId)
+ .sort((a, b) => b.createdMs - a.createdMs)
+ return hits[0] ? { externalId: hits[0].externalId, name: hits[0].name } : null
+ }
+ db.listActiveClans = async () =>
+ [...store.clans.values()].filter((c) => !c.goneAt).map((c) => ({ ...c, serverName: c.serverId.toUpperCase() }))
+ db.listPublicForServer = async (serverId) =>
+ [...store.clans.values()].filter((c) => c.serverId === serverId && !c.goneAt)
+ db.listMembers = async (externalId) => {
+ const c = store.clans.get(externalId)
+ return (store.members.get(externalId) || []).map((m) => ({
+ ...m,
+ userId: store.links.get(m.steamId) || null,
+ online: c && store.online.has(m.steamId) ? 1 : 0,
+ }))
+ }
+ db.userIsMember = async (externalId, userId) =>
+ (store.members.get(externalId) || []).some((m) => store.links.get(m.steamId) === userId)
+ db.recentClanEvents = async () => store.recent || []
+ db.rememberName = async (steamId, name) => store.names.push({ steamId, name })
+
+ visibilityDb.getSetting = async (key) => (key === 'clans.roster.audience' ? rosterSetting : null)
+ serversModel.listForPolling = async () => servers
+
+ const clans = require('../model/clans/clans.model')
+ const provider = require('../model/clans/teamProvider')
+
+ return {
+ ctx,
+ store,
+ clans,
+ provider,
+ restore: () => {
+ Object.assign(db, originals.db)
+ Object.assign(visibilityDb, originals.visibilityDb)
+ Object.assign(serversModel, originals.servers)
+ },
+ }
+}
+
+const board = (clans, extra = {}) => ({ kind: 'clans', type: 'snapshot', t: T0, supported: true, truncated: false, enabled: true, clans, ...extra })
+
+// ── Identity ───────────────────────────────────────────────────────────────
+
+test('a clan is keyed on server, game id AND creation time (D52)', () => {
+ const { clans, restore } = setup()
+ try {
+ const a = clans.normaliseClan(SERVER, clanRow(1, T0, [member('76561198000000001', 1)]))
+ const b = clans.normaliseClan(SERVER, clanRow(1, T0 + 5000, [member('76561198000000001', 1)]))
+ // Same game id, different clan: a reset database re-used id 1.
+ assert.notStrictEqual(a.externalId, b.externalId)
+ assert.strictEqual(a.externalId, `main:1:${T0}`)
+
+ // No id, no creation time, or no name: there is nothing to key it on.
+ assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, name: 'x' }), null)
+ assert.strictEqual(clans.normaliseClan(SERVER, { createdMs: T0, name: 'x' }), null)
+ assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, createdMs: T0 }), null)
+
+ // A colour ends up in a style, so anything that is not #rrggbb is dropped.
+ assert.strictEqual(clans.normaliseClan(SERVER, clanRow(2, T0, [], { color: 'red;background:url(x)' })).color, null)
+ // A member whose Steam id is not one is dropped, not the clan.
+ const partial = clans.normaliseClan(SERVER, clanRow(3, T0, [member('7656'), { steamId: 'robert' }]))
+ assert.deepStrictEqual(partial.members.map((m) => m.steamId), ['7656'])
+ } finally {
+ restore()
+ }
+})
+
+// ── The board ──────────────────────────────────────────────────────────────
+
+test('a first board stores its clans and asks core to reconcile', async () => {
+ const { ctx, store, clans, restore } = setup()
+ try {
+ const result = await clans.applyBoard(SERVER, board([
+ clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002')]),
+ ]))
+ assert.strictEqual(result.applied, true)
+ assert.strictEqual(result.created, 1)
+ assert.strictEqual(store.clans.size, 1)
+ assert.strictEqual(store.members.get(`main:1:${T0}`).length, 2)
+ assert.strictEqual(ctx.teams.reconcile.calls.length, 1)
+
+ // Leaders of a brand-new clan reach core WITH the Team, not as a delta
+ // against a Team core does not hold yet.
+ assert.strictEqual(ctx.teams.publish.calls.length, 0)
+ } finally {
+ restore()
+ }
+})
+
+test('a board whose t has not moved is not applied again', async () => {
+ const { ctx, clans, store, restore } = setup()
+ try {
+ const b = board([clanRow(1, T0, [member('76561198000000001', 1)])])
+ await clans.applyBoard(SERVER, b)
+ store.members.clear()
+ const again = await clans.applyBoard(SERVER, b)
+ assert.strictEqual(again.applied, false)
+ assert.strictEqual(store.members.size, 0, 'nothing was rewritten')
+ assert.strictEqual(ctx.teams.reconcile.calls.length, 1)
+ } finally {
+ restore()
+ }
+})
+
+test('an unchanged roster is not rewritten when the board moves on', async () => {
+ const { clans, restore } = setup()
+ const db = require('../model/clans/clans.db')
+ try {
+ const members = [member('76561198000000001', 1)]
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, members)]))
+ const writes = db.replaceMembers.calls.length
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, members)], { t: T0 + 60000 }))
+ assert.strictEqual(db.replaceMembers.calls.length, writes)
+ } finally {
+ restore()
+ }
+})
+
+test('a complete board says a missing clan is gone; a truncated one does not (D55)', async () => {
+ const { clans, store, restore } = setup()
+ try {
+ await clans.applyBoard(SERVER, board([
+ clanRow(1, T0, [member('76561198000000001', 1)]),
+ clanRow(2, T0, [member('76561198000000002', 1)]),
+ ]))
+
+ // At the ceiling: clan 2 is not listed, and that proves nothing.
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 60000, truncated: true }))
+ assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null)
+
+ // A row this build could not read counts the same way.
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)]), { name: 'broken' }], { t: T0 + 90000 }))
+ assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null)
+
+ // Complete, and still not listed: now it is gone.
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 120000 }))
+ assert.ok(store.clans.get(`main:2:${T0}`).goneAt)
+ } finally {
+ restore()
+ }
+})
+
+test('a change of leader is published from the board diff (D54)', async () => {
+ const { ctx, clans, restore } = setup()
+ try {
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002', 2)])]))
+ await clans.applyBoard(SERVER, board(
+ [clanRow(1, T0, [member('76561198000000001', 2), member('76561198000000002', 1)])],
+ { t: T0 + 60000 },
+ ))
+ const kinds = ctx.teams.publish.calls.map(([e]) => `${e.kind}:${e.memberKey}`).sort()
+ assert.deepStrictEqual(kinds, [
+ 'team.leader.added:76561198000000002',
+ 'team.leader.removed:76561198000000001',
+ ])
+ } finally {
+ restore()
+ }
+})
+
+test('an unsupported board is recorded with its reason and touches no clan', async () => {
+ const { clans, store, restore } = setup()
+ try {
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])]))
+ const result = await clans.applyBoard(SERVER, {
+ kind: 'clans', t: T0 + 60000, supported: false, reason: 'held by a NexusClanBackend', clans: [],
+ })
+ assert.strictEqual(result.applied, false)
+ assert.strictEqual(store.boards.get(SERVER).supported, 0)
+ assert.match(store.boards.get(SERVER).reason, /Nexus/)
+ assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null, 'an unreadable server says nothing about its clans')
+
+ // No board at all: a plugin older than protocol 6. Recorded, nothing touched.
+ await clans.applyBoard(SERVER, undefined)
+ assert.match(store.boards.get(SERVER).reason, /protocol 6/)
+ assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null)
+ } finally {
+ restore()
+ }
+})
+
+// ── The events ─────────────────────────────────────────────────────────────
+
+test('each clan event is published as the Team kind core takes', async () => {
+ const { ctx, clans, restore } = setup()
+ try {
+ const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 }
+ await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' })
+ await clans.applyEvent(SERVER, { kind: 'clan.member.added', ...base, steamId: '76561198000000002', name: 'Bob' })
+ await clans.applyEvent(SERVER, { kind: 'clan.member.left', ...base, steamId: '76561198000000002', name: 'Bob' })
+ await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', bySteamId: '76561198000000001' })
+
+ assert.deepStrictEqual(ctx.teams.publish.calls.map(([e]) => [e.kind, e.memberKey]), [
+ ['team.created', undefined],
+ ['team.member.added', '76561198000000002'],
+ ['team.member.removed', '76561198000000002'],
+ ['team.member.removed', '76561198000000003'],
+ ])
+ for (const [e] of ctx.teams.publish.calls) assert.strictEqual(e.externalId, `main:1:${T0}`)
+ } finally {
+ restore()
+ }
+})
+
+test('every feed item is members-only, and its dedupe key fits core’s 40 characters', async () => {
+ const { ctx, clans, restore } = setup()
+ try {
+ const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 }
+ await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' })
+ await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', name: 'Cy', byName: 'Ann', bySteamId: '76561198000000001' })
+ await clans.applyEvent(SERVER, { kind: 'clan.disbanded', ...base, steamId: '76561198000000001' })
+
+ const items = ctx.teams.activity.push.calls.map(([batch]) => batch[0])
+ // D49: founded and removed made lines; the disband did not.
+ assert.deepStrictEqual(items.map((i) => i.kind), ['rust.clan.founded', 'rust.clan.removed'])
+ assert.strictEqual(items[0].summary, 'Ann founded the clan.')
+ assert.strictEqual(items[1].summary, 'Cy was removed from the clan by Ann.')
+ assert.strictEqual(items[1].actorMemberKey, '76561198000000001', 'the actor of a kick is the kicker')
+ for (const item of items) {
+ assert.strictEqual(item.visibility, 'members')
+ // Core clamps a dedupe key to 40 characters. A readable one would be cut
+ // short into collisions; a sha1 is exactly 40.
+ assert.match(item.dedupeKey, /^[0-9a-f]{40}$/)
+ assert.strictEqual(typeof item.occurredAt, 'number', 'core reads occurredAt as epoch ms')
+ }
+ assert.notStrictEqual(items[0].dedupeKey, items[1].dedupeKey)
+ } finally {
+ restore()
+ }
+})
+
+test('the same frame offered twice carries the same key, so a re-offer is a no-op', async () => {
+ const { ctx, clans, store, restore } = setup()
+ try {
+ const frame = { kind: 'clan.member.added', clanId: 1, createdMs: T0, t: T0 + 5, steamId: '76561198000000002', name: 'Bob' }
+ await clans.applyEvent(SERVER, frame)
+ store.recent = [{ id: 1, kind: frame.kind, t: frame.t, raw: JSON.stringify(frame) }]
+ const offered = await clans.reofferActivity(SERVER)
+ assert.strictEqual(offered, 1)
+ const [first, second] = ctx.teams.activity.push.calls.map(([batch]) => batch[0].dedupeKey)
+ assert.strictEqual(first, second)
+ } finally {
+ restore()
+ }
+})
+
+test('a join without a creation time is matched on the game id, newest clan first', async () => {
+ const { ctx, clans, restore } = setup()
+ try {
+ await clans.applyBoard(SERVER, board([
+ clanRow(1, T0, [member('76561198000000001', 1)]),
+ ]))
+ const result = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 1, t: T0 + 1, steamId: '76561198000000009' })
+ assert.strictEqual(result.externalId, `main:1:${T0}`)
+
+ // A clan this module has never heard of is skipped, not guessed at.
+ const unknown = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 77, t: T0 + 2, steamId: '76561198000000009' })
+ assert.strictEqual(unknown.applied, false)
+ assert.ok(ctx.teams.publish.calls.every(([e]) => e.externalId === `main:1:${T0}`))
+ } finally {
+ restore()
+ }
+})
+
+test('a disband marks the clan gone even when the board could not say so', async () => {
+ const { clans, store, restore } = setup()
+ try {
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { truncated: true }))
+ await clans.applyEvent(SERVER, { kind: 'clan.disbanded', clanId: 1, createdMs: T0, t: T0 + 1, steamId: '76561198000000001' })
+ assert.ok(store.clans.get(`main:1:${T0}`).goneAt)
+ } finally {
+ restore()
+ }
+})
+
+// ── The provider ───────────────────────────────────────────────────────────
+
+test('getTeams is complete only when every server vouches (D53)', async () => {
+ const both = [{ id: 'main' }, { id: 'pvp' }]
+ const { clans, provider, restore } = setup({ servers: both })
+ try {
+ // Neither server has a board: refuse, never "no teams".
+ const none = await provider.getTeams()
+ assert.strictEqual(none.ok, false)
+
+ // One current, one never heard from: partial, so core removes nothing.
+ await clans.applyBoard('main', board([clanRow(1, T0, [member('76561198000000001', 1)])]))
+ const partial = await provider.getTeams()
+ assert.strictEqual(partial.ok, true)
+ assert.strictEqual(partial.complete, false)
+ assert.deepStrictEqual(partial.teams.map((t) => t.externalId), [`main:1:${T0}`])
+ assert.strictEqual(partial.teams[0].meta.serverId, 'main')
+
+ // Both current: complete.
+ await clans.applyBoard('pvp', board([]))
+ assert.strictEqual((await provider.getTeams()).complete, true)
+
+ // One at the ceiling: partial again.
+ await clans.applyBoard('pvp', board([], { t: T0 + 60000, truncated: true }))
+ assert.strictEqual((await provider.getTeams()).complete, false)
+ } finally {
+ restore()
+ }
+})
+
+test('a board that stops advancing stops vouching', async () => {
+ const { store, clans, provider, restore } = setup()
+ try {
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])]))
+ assert.strictEqual((await provider.getTeams()).ok, true)
+ store.boards.get(SERVER).seenAt = new Date(Date.now() - clans.FRESH_MS - 1000)
+ assert.strictEqual((await provider.getTeams()).ok, false)
+ assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false)
+ } finally {
+ restore()
+ }
+})
+
+test('getTeams refuses on a site with no Rust servers', async () => {
+ const { provider, restore } = setup({ servers: [] })
+ try {
+ const answer = await provider.getTeams()
+ assert.deepStrictEqual(answer.ok, false)
+ assert.match(answer.reason, /no Rust servers/)
+ } finally {
+ restore()
+ }
+})
+
+test('a roster names its members, its leaders, the linked account and who is on', async () => {
+ const { store, clans, provider, restore } = setup()
+ try {
+ await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002'), member('76561198000000003', null, { rank: null, role: null })])]))
+ store.links.set('76561198000000002', 42)
+ store.online.add('76561198000000001')
+
+ const roster = await provider.getTeamMembers(`main:1:${T0}`)
+ assert.strictEqual(roster.ok, true)
+ const byKey = Object.fromEntries(roster.members.map((m) => [m.memberKey, m]))
+ assert.strictEqual(byKey['76561198000000001'].leader, true)
+ assert.strictEqual(byKey['76561198000000001'].online, true)
+ assert.strictEqual(byKey['76561198000000002'].userId, 42)
+ // A rank the board could not match is not a leader.
+ assert.strictEqual(byKey['76561198000000003'].leader, false)
+
+ const leaders = await provider.getTeamLeaders(`main:1:${T0}`)
+ assert.deepStrictEqual(leaders, { ok: true, leaders: ['76561198000000001'] })
+
+ // A clan with a count but no stored rows is a read between two writes.
+ store.members.set(`main:1:${T0}`, [])
+ assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false)
+ } finally {
+ restore()
+ }
+})
+
+// ── Who may see a roster (D48) ─────────────────────────────────────────────
+
+const USERS = {
+ 1: { id: 1, role: 'player', status: 'active' }, // linked to a member
+ 2: { id: 2, role: 'player', status: 'active' }, // not a member
+ 3: { id: 3, role: 'moderator', status: 'active' },
+ 4: { id: 4, role: 'player', status: 'banned' }, // linked to a member, banned
+}
+
+async function rosterFixture(options) {
+ const fx = setup({ users: USERS, ...options })
+ await fx.clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000004')])]))
+ fx.store.links.set('76561198000000001', 1)
+ fx.store.links.set('76561198000000004', 4)
+ return fx
+}
+
+const keysFor = async (provider, viewer) =>
+ (await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }, { member_key: '76561198000000004' }], viewer)).members.length
+
+test('by default a roster is for the clan’s own members and staff', async () => {
+ const { provider, restore } = await rosterFixture()
+ try {
+ assert.strictEqual(await keysFor(provider, null), 0, 'anonymous')
+ assert.strictEqual(await keysFor(provider, { userId: 2, role: 'player' }), 0, 'a stranger')
+ assert.strictEqual(await keysFor(provider, { userId: 1, role: 'player' }), 2, 'a member')
+ assert.strictEqual(await keysFor(provider, { userId: 3, role: 'moderator' }), 2, 'staff')
+ // The row, not the claim: a banned member sees nothing, and a claimed role
+ // the row does not hold grants nothing.
+ assert.strictEqual(await keysFor(provider, { userId: 4, role: 'player' }), 0, 'banned')
+ assert.strictEqual(await keysFor(provider, { userId: 2, role: 'admin' }), 0, 'a claim is not a role')
+ } finally {
+ restore()
+ }
+})
+
+test('the operator can widen it, and an unknown setting narrows back', async () => {
+ const signedIn = await rosterFixture({ rosterSetting: 'signed_in' })
+ try {
+ assert.strictEqual(await keysFor(signedIn.provider, { userId: 2, role: 'player' }), 2)
+ assert.strictEqual(await keysFor(signedIn.provider, null), 0)
+ } finally {
+ signedIn.restore()
+ }
+
+ const open = await rosterFixture({ rosterSetting: 'public' })
+ try {
+ assert.strictEqual(await keysFor(open.provider, null), 2)
+ } finally {
+ open.restore()
+ }
+
+ const typo = await rosterFixture({ rosterSetting: 'everyone' })
+ try {
+ assert.strictEqual(await keysFor(typo.provider, { userId: 2, role: 'player' }), 0)
+ } finally {
+ typo.restore()
+ }
+})
+
+test('a roster question that cannot be answered withholds the roster', async () => {
+ const { provider, restore } = await rosterFixture()
+ const visibilityDb = require('../model/visibility/visibility.db')
+ try {
+ visibilityDb.getSetting = async () => {
+ throw new Error('pool exhausted')
+ }
+ const answer = await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }], { userId: 3 })
+ // Core fails CLOSED on this one call: a refusal serves an empty roster.
+ assert.strictEqual(answer.ok, false)
+ } finally {
+ restore()
+ }
+})
+
+test('the clan page carries no Steam id and no account id, and no names below the audience', async () => {
+ const { clans, restore } = await rosterFixture()
+ try {
+ const outside = await clans.getForViewer(`main:1:${T0}`, null)
+ assert.strictEqual(outside.roster.visible, false)
+ assert.deepStrictEqual(outside.roster.members, [])
+ assert.strictEqual(outside.clan.memberCount, 2, 'the count is public (D58)')
+
+ const inside = await clans.getForViewer(`main:1:${T0}`, { userId: 1 })
+ assert.strictEqual(inside.roster.visible, true)
+ assert.strictEqual(inside.roster.members.length, 2)
+ for (const m of inside.roster.members) {
+ assert.ok(!('steamId' in m) && !('userId' in m), 'no identifier leaves on a roster row')
+ }
+ assert.strictEqual(await clans.getForViewer('main:99:1', null), null)
+ } finally {
+ restore()
+ }
+})
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 0dd71ef..2204fb2 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -116,6 +116,19 @@ test('the manifest declares no extension slot it does not fill', () => {
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
})
+test('the Team provider is registered, whole, with the page core links to (phase 9)', () => {
+ const { api } = register()
+ const provider = api.record.teamProvider
+
+ // The three required methods, the optional fourth (D48's roster audience),
+ // and the fifth member, which is DATA: core substitutes `{externalId}` and
+ // nothing else, so the page cannot be nested under its server (D56).
+ for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders', 'projectRoster']) {
+ assert.strictEqual(typeof provider[name], 'function', `${name} must be a function`)
+ }
+ assert.strictEqual(provider.pageUrlTemplate, '/rust/clans/{externalId}')
+})
+
test('nothing is registered that has nothing behind it yet', () => {
const { api } = register()
@@ -124,8 +137,7 @@ test('nothing is registered that has nothing behind it yet', () => {
// surfaces an operator can configure and then wait on — worse than an absent
// one, because the absence is visible. Each of these arrives with the phase
// that has something real to put in it, and this assertion is what that phase
- // deletes.
- assert.strictEqual(api.record.teamProvider, null)
+ // deletes. Phase 9 deleted the Team provider's line.
assert.strictEqual(api.record.triggers, null)
assert.strictEqual(api.record.audiences, null)
assert.strictEqual(api.record.engagementSeeds, null)
diff --git a/server/test/links.test.js b/server/test/links.test.js
index 3a47cb5..4d9ab51 100644
--- a/server/test/links.test.js
+++ b/server/test/links.test.js
@@ -292,3 +292,28 @@ test('a player sees the name the GAME last saw, not the one they linked under',
const again = require('../model/links/links.model')
assert.equal((await again.listForUser(4))[0].name, 'Wanderer-old')
})
+
+test('a new link and a removed one ask core to reconcile Teams (D57)', async () => {
+ // A clan member's website account comes from this table. Without the request,
+ // somebody who links today is not in their clan's Team until core's next
+ // scheduled sweep.
+ const { ctx } = withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] })
+ const links = require('../model/links/links.model')
+ fleetOf({ a: linkOk('7656', 'Wanderer') })
+
+ await links.redeem({ code: 'K7M2PQ', userId: 4 })
+ assert.equal(ctx.teams.reconcile.calls.length, 1)
+
+ await links.unlinkAnyOwner('7656')
+ assert.equal(ctx.teams.reconcile.calls.length, 2)
+})
+
+test('a link that was already there asks for nothing', async () => {
+ const { ctx } = withCore({ select: [[{ steamId: '7656', userId: 4 }]] })
+ const links = require('../model/links/links.model')
+ fleetOf({ a: linkOk('7656', 'Wanderer') })
+
+ const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
+ assert.equal(result.already, true)
+ assert.equal(ctx.teams.reconcile.calls.length, 0)
+})
diff --git a/server/test/visibility.test.js b/server/test/visibility.test.js
index 318b4a2..077aad6 100644
--- a/server/test/visibility.test.js
+++ b/server/test/visibility.test.js
@@ -163,6 +163,28 @@ test('an update naming an unknown audience or server writes nothing at all', asy
}
})
+test('the clan roster audience defaults to members, and a bad one writes nothing (D48)', async () => {
+ const { model, written, restore } = setup({ overrides: { main: null } })
+ try {
+ // Nothing stored: the clan's own members and staff. The presence value the
+ // stub answers ('staff' is not a clan rung) must not leak across keys.
+ assert.equal(await model.clanRosterAudience(), 'members')
+ assert.equal((await model.describe()).clans.roster, 'members')
+
+ const bad = await model.update({ clanRoster: 'staff' })
+ assert.equal(bad.ok, false)
+ assert.equal(bad.status, 400)
+ assert.deepEqual(written.settings, [])
+
+ const ok = await model.update({ clanRoster: 'signed_in' }, { id: 7 })
+ assert.equal(ok.ok, true)
+ assert.deepEqual(written.settings, [{ key: 'clans.roster.audience', value: 'signed_in', userId: 7 }])
+ assert.equal(ok.changed.clanRoster, 'signed_in')
+ } finally {
+ restore()
+ }
+})
+
// ── The public routes ─────────────────────────────────────────────────────
/** A response double recording what a handler answered. */
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 0a3858f..0bc1119 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -688,8 +688,8 @@
"tags": [
"Admin · Rust"
],
- "summary": "Who may see who is online",
- "description": "The fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.",
+ "summary": "Who may see who is online, and who may see a clan roster",
+ "description": "The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.",
"responses": {
"200": {
"description": "The fleet default and each server",
@@ -710,8 +710,8 @@
"tags": [
"Admin · Rust"
],
- "summary": "Change who may see who is online",
- "description": "Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.",
+ "summary": "Change who may see who is online, or who may see a clan roster",
+ "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
"responses": {
"200": {
"description": "Saved; answers the new state",
@@ -1246,6 +1246,44 @@
}
}
},
+ "/api/v1/public/rust/clans/{externalId}": {
+ "get": {
+ "tags": [
+ "Public · Rust"
+ ],
+ "summary": "One Rust clan",
+ "description": "A clan and, when the viewer is inside the operator’s clan roster audience, its roster. The audience defaults to the clan’s own members (a website account linked to one of them) and staff. Below it the clan is still described and `roster.visible` is false with no names. The roster never carries a Steam id or a website account id. `externalId` is `::`, the same identity the site’s Team pages use.",
+ "parameters": [
+ {
+ "name": "externalId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The clan’s identity: server, clan id and creation time in epoch ms, joined by colons"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The clan",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustClan"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such clan"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
"/api/v1/public/rust/servers": {
"get": {
"tags": [
@@ -1301,6 +1339,41 @@
}
}
},
+ "/api/v1/public/rust/servers/{id}/clans": {
+ "get": {
+ "tags": [
+ "Public · Rust"
+ ],
+ "summary": "The clans on one Rust server",
+ "description": "Every clan on the server’s clan board, best score first: name, colour, score and member count. Public, because none of it names a player. `board` says whether the list is current and complete — a server whose bridge cannot read its clans answers an empty list and the reason, and a server at the game’s 100-clan ceiling says `truncated`.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The server’s slug"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The clans",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustClanList"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
"/api/v1/public/rust/servers/{id}/events": {
"get": {
"tags": [
@@ -4263,6 +4336,756 @@
}
}
}
+ },
+ "clans": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Who may see a clan roster, and each server’s clan board."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "audiences": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustClanAudience"
+ }
+ }
+ },
+ "roster": {
+ "$ref": "#/components/schemas/RustClanAudience"
+ },
+ "servers": {
+ "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": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Main · Vanilla"
+ }
+ }
+ },
+ "supported": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "enabled": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "fresh": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "truncated": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "reason": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "clans": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 14
+ }
+ }
+ },
+ "umodClans": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Is the uMod Clans plugin loaded? Its clans are a separate system and are not Teams."
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustClanAudience": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "members",
+ "signed_in",
+ "public"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "Who may see a clan’s roster: the clan’s own members (a website account linked to one of them) and staff, any signed-in account, or anybody. Widening it also shows which members are online to that audience."
+ },
+ "example": {
+ "type": "string",
+ "example": "members"
+ }
+ }
+ },
+ "RustClanBoard": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Whether a server’s clan list can be trusted right now."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "supported": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Could the bridge read this server’s clans at all?"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "enabled": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Is the game’s clan system switched on?"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "fresh": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Has the board been re-sent within the last three minutes?"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "truncated": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "At the game’s 100-clan ceiling, or too large for one line: there may be clans the list does not show."
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "reason": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Why the clans cannot be read, when they cannot."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustClanList": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One server’s clans (GET /public/rust/servers/{id}/clans). Public: nothing here names a player."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "clans": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "externalId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main:12:1790142840535"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Northwatch"
+ }
+ }
+ },
+ "color": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "#3fa9f5"
+ }
+ }
+ },
+ "score": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 140
+ }
+ }
+ },
+ "memberCount": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 6
+ }
+ }
+ },
+ "maxMembers": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 100
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "board": {
+ "$ref": "#/components/schemas/RustClanBoard"
+ }
+ }
+ }
+ }
+ },
+ "RustClan": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One clan (GET /public/rust/clans/{externalId}) and, inside the roster audience, its roster."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "clan": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "externalId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main:12:1790142840535"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Northwatch"
+ }
+ }
+ },
+ "color": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "#3fa9f5"
+ }
+ }
+ },
+ "score": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 140
+ }
+ }
+ },
+ "memberCount": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 6
+ }
+ }
+ },
+ "maxMembers": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 100
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "founded": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "When the clan was founded, epoch milliseconds."
+ }
+ }
+ },
+ "gone": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "The clan has been disbanded, or has left its server’s board."
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "roster": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "visible": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "Is this viewer inside the roster audience? When false, `members` is empty."
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "audience": {
+ "$ref": "#/components/schemas/RustClanAudience"
+ },
+ "members": {
+ "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"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "Wanderer"
+ }
+ }
+ },
+ "role": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "Leader"
+ }
+ }
+ },
+ "leader": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "online": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "joined": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Epoch milliseconds."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
@@ -4326,6 +5149,9 @@
}
}
}
+ },
+ "clanRoster": {
+ "$ref": "#/components/schemas/RustClanAudience"
}
}
}
--
2.49.1
From 285db0baa7465ca7c4c045ab745031b403419d49 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 23 Sep 2026 06:06:08 -0500
Subject: [PATCH 12/51] feat(rust): notifications and engagement (phase 10,
protocol 7)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Registers the engagement set R7 put in v1: thirteen triggers, four push
streams, three audiences, four bodies (two triggers, email and in-app)
and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68).
The raid alert goes to everyone authorised on the tool cupboard, one
emit per linked person with ownerUserId, so the owner ceiling holds per
emit. It covers doors and walls (protocol 7), never names the raider,
alerts nobody when there is no cupboard, and carries ownerOnline so
"offline only" is the seeded rule's condition rather than code.
The fan-out runs off ingest before a frame is applied, since applying a
disband deletes the roster the notice is sent to. A replayed event is
told only while it is news: 15 minutes for broadcasts, 24 hours for
personal and staff events. Dedupe keys come from the event, not the
sidecar's row id. Server online/offline and a new kills leader are
in-memory transitions, never on first sight, and a tie is not a lead.
A login with no approval within a minute becomes a staff notice via a
query, so a restart loses nothing.
Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game
that hung, or whose bridge was unloaded, while the sidecar stayed up no
longer reads as online. It stops naming players as online, and a stale
board no longer moves "last seen".
engagement-triggers.json is the committed freeze of all of it, checked
in CI with line endings normalised. The check was verified by breaking
it both ways.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
.gitea/workflows/pr-checks.yml | 3 +
ci/bundle.json | 2 +
engagement-triggers.json | 1021 ++++++++++++++++++++++++++
server/boot.js | 74 +-
server/engagement/audiences.js | 107 +++
server/engagement/emit.js | 490 ++++++++++++
server/engagement/seeds.js | 315 ++++++++
server/engagement/streams.js | 53 ++
server/engagement/triggers.js | 354 +++++++++
server/index.js | 39 +-
server/ingest.js | 17 +-
server/model/clans/clans.model.js | 1 +
server/model/events/events.db.js | 33 +
server/model/links/links.db.js | 18 +
server/model/links/links.model.js | 5 +
server/model/servers/servers.db.js | 13 +-
server/package.json | 4 +-
server/scripts/engagementManifest.js | 146 ++++
server/sidecarClient.js | 8 +-
server/test/engagement.test.js | 475 ++++++++++++
server/test/entry.test.js | 29 +-
server/test/refresh.test.js | 81 ++
22 files changed, 3256 insertions(+), 32 deletions(-)
create mode 100644 engagement-triggers.json
create mode 100644 server/engagement/audiences.js
create mode 100644 server/engagement/emit.js
create mode 100644 server/engagement/seeds.js
create mode 100644 server/engagement/streams.js
create mode 100644 server/engagement/triggers.js
create mode 100644 server/scripts/engagementManifest.js
create mode 100644 server/test/engagement.test.js
diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml
index 59b19eb..52e9258 100644
--- a/.gitea/workflows/pr-checks.yml
+++ b/.gitea/workflows/pr-checks.yml
@@ -130,6 +130,9 @@ jobs:
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
run: npm run check:swagger --prefix server
+ - name: Check the engagement freeze is current (PLAN.md §25)
+ run: npm run check:engagement --prefix server
+
client-build:
runs-on: ubuntu-latest
timeout-minutes: 20
diff --git a/ci/bundle.json b/ci/bundle.json
index b0b374d..ce75ece 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -32,6 +32,7 @@
"configEdit.js",
"core.js",
"db",
+ "engagement",
"index.js",
"ingest.js",
"model",
@@ -41,6 +42,7 @@
"sidecarClient.js"
],
"root": [
+ "engagement-triggers.json",
"swagger-fragment.json",
"LICENSE.md",
"README.md"
diff --git a/engagement-triggers.json b/engagement-triggers.json
new file mode 100644
index 0000000..bc2b357
--- /dev/null
+++ b/engagement-triggers.json
@@ -0,0 +1,1021 @@
+{
+ "_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
+ "coreApi": "^1.10.0",
+ "triggers": [
+ {
+ "id": "rust.base.destroyed",
+ "label": "Your base was raided",
+ "description": "Part of a base you are authorised on was destroyed by another player: a wall, a door, an external wall or gate, or the tool cupboard.",
+ "kind": "event",
+ "subjectKey": "building",
+ "audience": "owner",
+ "ceiling": "owner",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "building",
+ "type": "string",
+ "required": true,
+ "example": "8113",
+ "description": "The base, as the id of its tool cupboard. The cooldown subject."
+ },
+ {
+ "name": "structure",
+ "type": "string",
+ "required": true,
+ "example": "door",
+ "description": "What was destroyed: \"building block\", \"door\", \"external wall\" or \"tool cupboard\"."
+ },
+ {
+ "name": "grid",
+ "type": "string",
+ "required": false,
+ "example": "H7",
+ "description": "The map grid square. Absent when the server could not work one out."
+ },
+ {
+ "name": "atGrid",
+ "type": "string",
+ "required": false,
+ "example": " in H7",
+ "description": "Sentence fragment: \" in H7\" with its own leading space, or nothing when the grid is unknown."
+ },
+ {
+ "name": "ownerOnline",
+ "type": "boolean",
+ "required": true,
+ "example": false,
+ "description": "Whether YOU were online when it happened. The seeded rule alerts only when this is false."
+ }
+ ]
+ },
+ {
+ "id": "rust.clan.disbanded",
+ "label": "Your clan was disbanded",
+ "description": "A clan you were in was disbanded.",
+ "kind": "event",
+ "subjectKey": "clanKey",
+ "audience": "members",
+ "ceiling": "members",
+ "version": 1,
+ "variables": [
+ {
+ "name": "clanKey",
+ "type": "string",
+ "required": true,
+ "example": "main:12:1790142840000",
+ "description": "The clan's stable identity. The cooldown subject; not meant for display."
+ },
+ {
+ "name": "clan",
+ "type": "string",
+ "required": true,
+ "example": "The Rust Belt",
+ "description": "The clan's name."
+ },
+ {
+ "name": "clanUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/clans/main%3A12%3A1790142840000",
+ "description": "Site-relative path to the clan's page."
+ },
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "by",
+ "type": "string",
+ "required": false,
+ "example": "Marisol",
+ "description": "Who disbanded it."
+ }
+ ]
+ },
+ {
+ "id": "rust.clan.member.kicked",
+ "label": "Someone was removed from your clan",
+ "description": "A member was removed from a clan you are in — or you were.",
+ "kind": "event",
+ "subjectKey": "clanKey",
+ "audience": "members",
+ "ceiling": "members",
+ "version": 1,
+ "variables": [
+ {
+ "name": "clanKey",
+ "type": "string",
+ "required": true,
+ "example": "main:12:1790142840000",
+ "description": "The clan's stable identity. The cooldown subject; not meant for display."
+ },
+ {
+ "name": "clan",
+ "type": "string",
+ "required": true,
+ "example": "The Rust Belt",
+ "description": "The clan's name."
+ },
+ {
+ "name": "clanUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/clans/main%3A12%3A1790142840000",
+ "description": "Site-relative path to the clan's page."
+ },
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "member",
+ "type": "string",
+ "required": false,
+ "example": "Darrow",
+ "description": "Who was removed."
+ },
+ {
+ "name": "by",
+ "type": "string",
+ "required": false,
+ "example": "Marisol",
+ "description": "Who removed them."
+ }
+ ]
+ },
+ {
+ "id": "rust.clan.member.left",
+ "label": "Someone left your clan",
+ "description": "A member left a clan you are in.",
+ "kind": "event",
+ "subjectKey": "clanKey",
+ "audience": "members",
+ "ceiling": "members",
+ "version": 1,
+ "variables": [
+ {
+ "name": "clanKey",
+ "type": "string",
+ "required": true,
+ "example": "main:12:1790142840000",
+ "description": "The clan's stable identity. The cooldown subject; not meant for display."
+ },
+ {
+ "name": "clan",
+ "type": "string",
+ "required": true,
+ "example": "The Rust Belt",
+ "description": "The clan's name."
+ },
+ {
+ "name": "clanUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/clans/main%3A12%3A1790142840000",
+ "description": "Site-relative path to the clan's page."
+ },
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "member",
+ "type": "string",
+ "required": false,
+ "example": "Darrow",
+ "description": "Who left."
+ }
+ ]
+ },
+ {
+ "id": "rust.leaderboard.topped",
+ "label": "A new kills leader",
+ "description": "Somebody new leads the current wipe's kills on a server.",
+ "kind": "event",
+ "subjectKey": "serverId",
+ "audience": "subscribers",
+ "ceiling": "everyone",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "leader",
+ "type": "string",
+ "required": true,
+ "example": "Marisol",
+ "description": "The new leader's in-game name."
+ },
+ {
+ "name": "kills",
+ "type": "int",
+ "required": true,
+ "example": 42,
+ "description": "Their kills this wipe."
+ },
+ {
+ "name": "leaderboardUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main?tab=leaderboard",
+ "description": "Site-relative path to the server's leaderboard."
+ }
+ ]
+ },
+ {
+ "id": "rust.login.denied",
+ "label": "A login was not approved",
+ "description": "Somebody tried to join a server and was not let in within a minute: a ban, a failed authentication, or a player who gave up while connecting.",
+ "kind": "event",
+ "subjectKey": "steamId",
+ "audience": "staff",
+ "ceiling": "staff",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "steamId",
+ "type": "string",
+ "required": true,
+ "example": "76561198000000002",
+ "description": "The Steam id that tried to connect. The cooldown subject."
+ },
+ {
+ "name": "player",
+ "type": "string",
+ "required": false,
+ "example": "Darrow",
+ "description": "The name it connected with."
+ },
+ {
+ "name": "attemptedAt",
+ "type": "datetime",
+ "required": true,
+ "example": "2026-09-23T03:10:00Z",
+ "description": "When the attempt was made."
+ }
+ ]
+ },
+ {
+ "id": "rust.player.banned",
+ "label": "A player was banned",
+ "description": "A player was banned on a server.",
+ "kind": "event",
+ "subjectKey": "steamId",
+ "audience": "staff",
+ "ceiling": "staff",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "steamId",
+ "type": "string",
+ "required": true,
+ "example": "76561198000000002",
+ "description": "The banned player's Steam id. The cooldown subject."
+ },
+ {
+ "name": "player",
+ "type": "string",
+ "required": false,
+ "example": "Darrow",
+ "description": "The banned player's name."
+ },
+ {
+ "name": "reason",
+ "type": "string",
+ "required": false,
+ "example": "Cheating",
+ "description": "The reason given."
+ }
+ ]
+ },
+ {
+ "id": "rust.player.linked",
+ "label": "A Steam account was linked",
+ "description": "A Steam account was linked to your website account with an in-game code.",
+ "kind": "event",
+ "subjectKey": "steamId",
+ "audience": "owner",
+ "ceiling": "owner",
+ "version": 1,
+ "variables": [
+ {
+ "name": "steamId",
+ "type": "string",
+ "required": true,
+ "example": "76561198000000001",
+ "description": "The Steam account that was linked. Also the cooldown subject."
+ },
+ {
+ "name": "player",
+ "type": "string",
+ "required": false,
+ "example": "Marisol",
+ "description": "The in-game name the game reported when it was linked."
+ },
+ {
+ "name": "accountUrl",
+ "type": "url",
+ "required": false,
+ "example": "/player/rust",
+ "description": "Site-relative path to your Rust account page."
+ }
+ ]
+ },
+ {
+ "id": "rust.player.reported",
+ "label": "A player was reported",
+ "description": "A player filed an in-game report against another.",
+ "kind": "event",
+ "subjectKey": "steamId",
+ "audience": "staff",
+ "ceiling": "staff",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "steamId",
+ "type": "string",
+ "required": true,
+ "example": "76561198000000002",
+ "description": "The reported player's Steam id. The cooldown subject."
+ },
+ {
+ "name": "player",
+ "type": "string",
+ "required": false,
+ "example": "Darrow",
+ "description": "The reported player's name."
+ },
+ {
+ "name": "reporter",
+ "type": "string",
+ "required": false,
+ "example": "Marisol",
+ "description": "Who filed the report."
+ },
+ {
+ "name": "reportType",
+ "type": "string",
+ "required": false,
+ "example": "cheat",
+ "description": "The category the reporter chose."
+ },
+ {
+ "name": "topic",
+ "type": "string",
+ "required": false,
+ "example": "Aimbot at the dome",
+ "description": "The report's subject line."
+ },
+ {
+ "name": "message",
+ "type": "string",
+ "required": false,
+ "example": "Headshots through two walls.",
+ "description": "The report's text."
+ }
+ ]
+ },
+ {
+ "id": "rust.player.unbanned",
+ "label": "A player was unbanned",
+ "description": "A ban on a server was lifted.",
+ "kind": "event",
+ "subjectKey": "steamId",
+ "audience": "staff",
+ "ceiling": "staff",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "steamId",
+ "type": "string",
+ "required": true,
+ "example": "76561198000000002",
+ "description": "The player's Steam id. The cooldown subject."
+ },
+ {
+ "name": "player",
+ "type": "string",
+ "required": false,
+ "example": "Darrow",
+ "description": "The player's name."
+ }
+ ]
+ },
+ {
+ "id": "rust.server.offline",
+ "label": "A server went offline",
+ "description": "A server's game stopped, crashed, or stopped talking to the website.",
+ "kind": "event",
+ "subjectKey": "serverId",
+ "audience": "subscribers",
+ "ceiling": "everyone",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ }
+ ]
+ },
+ {
+ "id": "rust.server.online",
+ "label": "A server came online",
+ "description": "A server's game started, or came back after being unreachable.",
+ "kind": "event",
+ "subjectKey": "serverId",
+ "audience": "subscribers",
+ "ceiling": "everyone",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ }
+ ]
+ },
+ {
+ "id": "rust.wipe.started",
+ "label": "A server wiped",
+ "description": "A server started a new wipe: a fresh map, and everything built on the old one gone.",
+ "kind": "event",
+ "subjectKey": "serverId",
+ "audience": "subscribers",
+ "ceiling": "everyone",
+ "version": 1,
+ "variables": [
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "wipeId",
+ "type": "string",
+ "required": true,
+ "example": "1790142840-3000-1234",
+ "description": "The new wipe's identity."
+ }
+ ]
+ }
+ ],
+ "streams": [
+ {
+ "id": "rust.base.destroyed",
+ "label": "Your base was raided",
+ "personal": true,
+ "requiresLinkedAccount": true
+ },
+ {
+ "id": "rust.server.offline",
+ "label": "A server went offline",
+ "personal": false,
+ "requiresLinkedAccount": false
+ },
+ {
+ "id": "rust.server.online",
+ "label": "A server came online",
+ "personal": false,
+ "requiresLinkedAccount": false
+ },
+ {
+ "id": "rust.wipe.started",
+ "label": "A server wiped",
+ "personal": false,
+ "requiresLinkedAccount": false
+ }
+ ],
+ "audiences": [
+ {
+ "id": "rust.clan.members",
+ "label": "Members of a clan",
+ "params": [
+ {
+ "id": "clan",
+ "type": "string",
+ "required": true
+ }
+ ],
+ "ceiling": "members"
+ },
+ {
+ "id": "rust.server.players",
+ "label": "Everyone who has played on a server",
+ "params": [
+ {
+ "id": "serverId",
+ "type": "string",
+ "required": true
+ }
+ ],
+ "ceiling": "authenticated"
+ },
+ {
+ "id": "rust.wipe.participants",
+ "label": "Everyone playing a server's current wipe",
+ "params": [
+ {
+ "id": "serverId",
+ "type": "string",
+ "required": true
+ }
+ ],
+ "ceiling": "authenticated"
+ }
+ ],
+ "ruleGroups": [
+ {
+ "key": "raid-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.base.destroyed",
+ "audience": "owner",
+ "channels": [
+ "email",
+ "inapp",
+ "push"
+ ],
+ "template_keys": {
+ "email": "rust.base.destroyed",
+ "inapp": "rust.base.destroyed-inapp",
+ "digest": "notify.digest"
+ },
+ "conditions": {
+ "variable": "ownerOnline",
+ "cmp": "eq",
+ "value": false
+ },
+ "cooldown_seconds": 1800,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ },
+ {
+ "key": "wipe-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.wipe.started",
+ "audience": "subscribers",
+ "channels": [
+ "email",
+ "inapp",
+ "push"
+ ],
+ "template_keys": {
+ "email": "rust.wipe.started",
+ "inapp": "rust.wipe.started-inapp",
+ "digest": "notify.digest"
+ },
+ "conditions": null,
+ "cooldown_seconds": 21600,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ },
+ {
+ "key": "server-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.server.online",
+ "audience": "subscribers",
+ "channels": [
+ "inapp",
+ "push"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 3600,
+ "delay_seconds": 0,
+ "cancel_on": []
+ },
+ {
+ "trigger_id": "rust.server.offline",
+ "audience": "subscribers",
+ "channels": [
+ "inapp",
+ "push"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 3600,
+ "delay_seconds": 300,
+ "cancel_on": [
+ "rust.server.online"
+ ]
+ }
+ ]
+ },
+ {
+ "key": "leaderboard-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.leaderboard.topped",
+ "audience": "subscribers",
+ "channels": [
+ "inapp"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 3600,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ },
+ {
+ "key": "account-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.player.linked",
+ "audience": "owner",
+ "channels": [
+ "email",
+ "inapp"
+ ],
+ "template_keys": {
+ "email": "notify.event",
+ "inapp": "inapp.event",
+ "digest": "notify.digest"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ },
+ {
+ "key": "clans-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.clan.member.left",
+ "audience": "members",
+ "channels": [
+ "inapp"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ },
+ {
+ "trigger_id": "rust.clan.member.kicked",
+ "audience": "members",
+ "channels": [
+ "inapp"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ },
+ {
+ "trigger_id": "rust.clan.disbanded",
+ "audience": "members",
+ "channels": [
+ "email",
+ "inapp"
+ ],
+ "template_keys": {
+ "email": "notify.event",
+ "inapp": "inapp.event",
+ "digest": "notify.digest"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ },
+ {
+ "key": "moderation-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.player.reported",
+ "audience": "staff",
+ "channels": [
+ "email",
+ "inapp"
+ ],
+ "template_keys": {
+ "email": "notify.event",
+ "inapp": "inapp.event",
+ "digest": "notify.digest"
+ },
+ "conditions": null,
+ "cooldown_seconds": 3600,
+ "delay_seconds": 0,
+ "cancel_on": []
+ },
+ {
+ "trigger_id": "rust.player.banned",
+ "audience": "staff",
+ "channels": [
+ "inapp"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ },
+ {
+ "trigger_id": "rust.player.unbanned",
+ "audience": "staff",
+ "channels": [
+ "inapp"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ },
+ {
+ "trigger_id": "rust.login.denied",
+ "audience": "staff",
+ "channels": [
+ "inapp"
+ ],
+ "template_keys": {
+ "inapp": "inapp.event"
+ },
+ "conditions": null,
+ "cooldown_seconds": 3600,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ }
+ ],
+ "templates": [
+ {
+ "key": "rust.base.destroyed",
+ "channel": "email",
+ "triggerId": "rust.base.destroyed",
+ "seedVersion": 1
+ },
+ {
+ "key": "rust.base.destroyed-inapp",
+ "channel": "inapp",
+ "triggerId": "rust.base.destroyed",
+ "seedVersion": 1
+ },
+ {
+ "key": "rust.wipe.started",
+ "channel": "email",
+ "triggerId": "rust.wipe.started",
+ "seedVersion": 1
+ },
+ {
+ "key": "rust.wipe.started-inapp",
+ "channel": "inapp",
+ "triggerId": "rust.wipe.started",
+ "seedVersion": 1
+ }
+ ]
+}
diff --git a/server/boot.js b/server/boot.js
index b4eb58d..e711dd5 100644
--- a/server/boot.js
+++ b/server/boot.js
@@ -21,10 +21,11 @@
// letting that fail the boot would make installing the module before installing
// the bridge impossible.
//
-// ── Three timers, and they answer three different questions ───────────────
+// ── Four timers, and they answer four different questions ─────────────────
//
// refresh (30s) what is each server, and who is on it — the BOARDS
// ingest (5s) what has happened since we last looked — the CURSOR
+// sweep (1m) which login attempts were never let in (PLAN.md §25)
// prune (1h) forgetting the detail we promised not to keep for ever
//
// The boards poll and the ingest are deliberately separate rather than one loop
@@ -42,6 +43,7 @@
const core = require('./core')
const db = require('./model/servers/servers.db')
+const engagement = require('./engagement/emit')
const eventsDb = require('./model/events/events.db')
const ingest = require('./ingest')
const permSync = require('./permSync')
@@ -53,10 +55,12 @@ const log = core.logger('boot')
let refreshTimer = null
let ingestTimer = null
let pruneTimer = null
+let sweepTimer = null
const REFRESH_MS = 30 * 1000
const INGEST_MS = 5 * 1000
const PRUNE_MS = 60 * 60 * 1000
+const SWEEP_MS = 60 * 1000
/**
* How long this module keeps raw events.
@@ -94,7 +98,15 @@ async function refreshOne(server) {
// One call for both boards. `/server` would answer the same question about
// the server itself, but presence would then be a second round trip to the
// same process for a fact it already had in hand.
- const board = await sidecar.boards(server)
+ //
+ // **And one for `/health`, because the boards cannot say whether the game is
+ // there NOW** (D68, PLAN.md §25.1). The sidecar keeps its last `server.hello`
+ // after the plugin disconnects — that is what lets a page render a server
+ // that is off — so a game that hung, or whose bridge was unloaded, while the
+ // sidecar stayed up read as online here from phase 4 until phase 10. Only
+ // `/health`'s `plugin_connected` answers the question, and the two are asked
+ // together so they describe the same moment.
+ const [board, health] = await Promise.all([sidecar.boards(server), sidecar.health(server)])
// Three outcomes, and collapsing any two of them loses something an operator
// needs:
@@ -113,6 +125,7 @@ async function refreshOne(server) {
// server said, which is exactly what the pages exist to render while it is
// off.
await db.markUnreachable(server.id, false)
+ engagement.serverObserved(server, false)
return
}
@@ -125,20 +138,34 @@ async function refreshOne(server) {
// reach is worse than an empty one, because it looks current.
await db.markUnreachable(server.id, true)
await ingest.applyBoards(server.id, {})
+ engagement.serverObserved(server, false)
return
}
- await ingest.applyBoards(server.id, boards)
+ // Unknown is not connected. A `/health` that did not answer while `/boards`
+ // did is odd enough to be worth a line, and reporting the server up on the
+ // strength of a board the game may have left behind hours ago is the defect
+ // this call exists to remove.
+ const connected = Boolean(health.ok && health.data && health.data.plugin_connected === true)
+ if (!health.ok) log.warn('the sidecar answered /boards but not /health', { server: server.id })
+
+ // The presence board is the plugin's last word too. While the game is not
+ // connected it names people as online who may have left hours ago, which is
+ // both wrong and — under §23's rule — a claim about named people nobody made.
+ await ingest.applyBoards(
+ server.id,
+ connected ? boards : { ...boards, 'players.online': { players: [] } },
+ )
await db.putState({
serverId: server.id,
reachable: true,
- // A stored `server.hello` means the game connected; whether it is connected
- // NOW is a different question, and `/health` is what answers it. The board
- // alone cannot say, which is why `online` is not simply `true` here — it is
- // decided by freshness in the model, from `updated_at`.
- online: true,
- players: Number(frame.players) || 0,
+ // The plugin is connected NOW (D68). A stored `server.hello` only says it
+ // connected once; the model still applies its own freshness on top.
+ online: connected,
+ // A board the game left behind is a description, not a sighting.
+ seen: connected,
+ players: connected ? Number(frame.players) || 0 : 0,
maxPlayers: Number(frame.maxPlayers) || 0,
hostname: frame.hostname || null,
level: frame.level || null,
@@ -150,6 +177,9 @@ async function refreshOne(server) {
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
raw: frame,
})
+
+ // After the write, so a transition announced is one a page already shows.
+ engagement.serverObserved(server, connected)
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`
// as a rejection that hides which one. Log with the id and carry on.
@@ -189,6 +219,26 @@ async function prune() {
}
}
+/**
+ * Login attempts that were never approved (D64, PLAN.md §25).
+ *
+ * On its own minute timer rather than the prune's hour: an attempt waits a
+ * minute for its approval, and a staff alert an hour late is not an alert. A
+ * query over stored rows, so it needs nothing kept in memory and a restart loses
+ * nothing; the dedupe key makes a second pass over the same attempt a no-op.
+ */
+async function sweep() {
+ let rows
+ try {
+ rows = await servers.listForPolling()
+ } catch (err) {
+ log.warn('could not read the server list', { error: err.message })
+ return
+ }
+ const sent = await engagement.sweepLoginDenied(rows)
+ if (sent > 0) log.info('unapproved logins reported', { attempts: sent })
+}
+
async function onBoot() {
await refresh()
// The permission mirror owns its own loop and its own cadence (see
@@ -199,10 +249,11 @@ async function onBoot() {
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
+ sweepTimer = setInterval(sweep, SWEEP_MS)
// Node keeps the process alive for a pending timer. Core's own intervals are
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
- for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
+ for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
if (timer && typeof timer.unref === 'function') timer.unref()
}
@@ -220,13 +271,14 @@ async function onBoot() {
async function onShutdown() {
permSync.stop()
- for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
+ for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
if (timer) clearInterval(timer)
}
refreshTimer = null
ingestTimer = null
pruneTimer = null
+ sweepTimer = null
log.info('shut down')
}
diff --git a/server/engagement/audiences.js b/server/engagement/audiences.js
new file mode 100644
index 0000000..fa46f51
--- /dev/null
+++ b/server/engagement/audiences.js
@@ -0,0 +1,107 @@
+// ── Named sets of people, over this module's own data ─────────────────────
+//
+// `registerAudiences` (MODULE_API.md §2.4; PLAN.md §25.2). An operator points a
+// rule or a segment at one of these; core calls `resolve` when a rule fires.
+//
+// Three properties, each the contract rather than a style:
+//
+// • **A resolver returns website user ids and nothing else.** Never an
+// address, a channel or a Steam id: core maps ids to people after the
+// preferences, the suppression list and the verification gate, and a module
+// that could hand it anything else would have a way to send mail.
+// • **One that fails answers NOBODY** — never everybody, never its last good
+// answer. A throw here is caught and returned as `[]`, and core treats a
+// throw the same way; both are here so the property does not rest on
+// either side alone.
+// • **Params are constants**, fixed when an operator saves the rule. "The clan
+// this event was about" is therefore not expressible as an audience — a
+// clan trigger carries its own recipients instead (`emit.js`).
+
+const core = require('../core')
+
+const log = core.logger('audiences')
+
+const LINKS = 'rust_account_links'
+
+/** Wraps a resolver so a failure is an empty set, logged, and never a throw. */
+function safe(id, fn) {
+ return async (params) => {
+ try {
+ const rows = await fn(params || {})
+ return rows.map((r) => Number(r.userId)).filter((n) => Number.isInteger(n) && n > 0)
+ } catch (err) {
+ log.warn('an audience could not be resolved; it answers nobody', { audience: id, error: err.message })
+ return []
+ }
+ }
+}
+
+const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null)
+
+const AUDIENCES = Object.freeze([
+ {
+ id: 'rust.clan.members',
+ label: 'Members of a clan',
+ params: [{ id: 'clan', type: 'string', required: true }],
+ ceiling: 'members',
+ // A clan's LINKED members, as the store holds them now. A clan that has
+ // been disbanded has no roster, so a rule saved against it resolves to
+ // nobody — which is the truth, and not the same as the audience being gone.
+ resolve: safe('rust.clan.members', async ({ clan }) => {
+ const key = text(clan)
+ if (!key) return []
+ return core.query(
+ `SELECT DISTINCT l.user_id AS userId
+ FROM rust_clan_members m
+ JOIN rust_clans c ON c.external_id = m.external_id AND c.gone_at IS NULL
+ JOIN ${LINKS} l ON l.steam_id = m.steam_id
+ WHERE m.external_id = ?`,
+ [key],
+ )
+ }),
+ },
+ {
+ id: 'rust.server.players',
+ label: 'Everyone who has played on a server',
+ params: [{ id: 'serverId', type: 'string', required: true }],
+ ceiling: 'authenticated',
+ // Linked accounts with a stats row on this server in ANY wipe. The stats
+ // table is the record of having played, and it outlives both wipes and the
+ // raw event history (R12).
+ resolve: safe('rust.server.players', async ({ serverId }) => {
+ const id = text(serverId)
+ if (!id) return []
+ return core.query(
+ `SELECT DISTINCT l.user_id AS userId
+ FROM rust_player_wipe_stats s
+ JOIN ${LINKS} l ON l.steam_id = s.steam_id
+ WHERE s.server_id = ?`,
+ [id],
+ )
+ }),
+ },
+ {
+ id: 'rust.wipe.participants',
+ label: 'Everyone playing a server\'s current wipe',
+ params: [{ id: 'serverId', type: 'string', required: true }],
+ ceiling: 'authenticated',
+ // The same, narrowed to the wipe the server is on NOW. Resolved at send
+ // time, so a rule saved last month reaches this month's players — which is
+ // what "current" has to mean for a parameter fixed when the rule was saved.
+ // A server with no known wipe resolves to nobody rather than to every wipe.
+ resolve: safe('rust.wipe.participants', async ({ serverId }) => {
+ const id = text(serverId)
+ if (!id) return []
+ return core.query(
+ `SELECT DISTINCT l.user_id AS userId
+ FROM rust_server_state st
+ JOIN rust_player_wipe_stats s ON s.server_id = st.server_id AND s.wipe_id = st.wipe_id
+ JOIN ${LINKS} l ON l.steam_id = s.steam_id
+ WHERE st.server_id = ? AND st.wipe_id IS NOT NULL`,
+ [id],
+ )
+ }),
+ },
+])
+
+module.exports = { AUDIENCES }
diff --git a/server/engagement/emit.js b/server/engagement/emit.js
new file mode 100644
index 0000000..89acc9c
--- /dev/null
+++ b/server/engagement/emit.js
@@ -0,0 +1,490 @@
+// ── What happened, told to core's engagement engine ───────────────────────
+//
+// The fan-out behind `triggers.js` (PLAN.md §25). Ingest calls `onEvent` for
+// every frame it stores; the refresh calls `serverObserved` for every poll;
+// ingest calls `checkLeader` after a batch; the prune timer calls
+// `sweepLoginDenied`; the link route calls `linked`.
+//
+// **Nothing here decides who is told.** It says what happened and, for a
+// personal or clan event, who it is ABOUT. Core applies the rule, the ceiling,
+// the preference, the suppression list and the verification gate. A module
+// cannot send mail (MODULE_API.md §2.7), and this file is not the back door.
+//
+// **Nothing here throws into its caller.** Every entry point catches, because
+// its callers are the ingest cursor and the refresh loop — a malformed frame or
+// a core-side contract problem must cost one notification and never the feed.
+//
+// ── Three rules the whole file follows ────────────────────────────────────
+//
+// 1. **Emit on the transition, never on the poll** (R7). A server that is
+// still up is not news. Transitions are tracked in memory, and a FIRST
+// sighting is never one — so a website restart announces nothing.
+//
+// 2. **A replayed event notifies only while it is still news** (D63). After an
+// outage the cursor replays hours of frames. A broadcast older than 15
+// minutes tells nobody; a personal or staff event is kept for 24 hours,
+// because "your base was raided at 03:10" is still true and still wanted.
+//
+// 3. **Every emit carries a dedupe key made from the EVENT, not the store.**
+// Core's outbox is unique on (rule, user, channel, key), so the same frame
+// replayed after a crash is a no-op. The key is built from what the event
+// says — its server, time and subject — rather than from the sidecar's row
+// id, because a sidecar whose database is replaced starts its ids again
+// and would otherwise have every new alert swallowed as a repeat of an old
+// one.
+
+const crypto = require('crypto')
+
+const core = require('../core')
+
+const clans = require('../model/clans/clans.model')
+const clansDb = require('../model/clans/clans.db')
+const eventsDb = require('../model/events/events.db')
+const linksDb = require('../model/links/links.db')
+const serversDb = require('../model/servers/servers.db')
+const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
+
+const log = core.logger('engagement')
+
+/** How old a broadcast may be and still be news (D63). */
+const BROADCAST_MAX_AGE_MS = 15 * 60 * 1000
+
+/** How old a personal or staff event may be and still be worth telling (D63). */
+const PERSONAL_MAX_AGE_MS = 24 * 60 * 60 * 1000
+
+/**
+ * How long a login attempt waits for its approval before it counts as denied
+ * (D64, PLAN.md §16.5). The game raises no rejection hook, so a denial is the
+ * ABSENCE of an approval — which is only knowable after a wait.
+ */
+const LOGIN_APPROVAL_WINDOW_MS = 60 * 1000
+
+/** How far BEFORE an attempt an approval may be stamped and still answer it — clock grain, not policy. */
+const LOGIN_APPROVAL_SLACK_MS = 5 * 1000
+
+const STRUCTURE_LABELS = Object.freeze({
+ block: 'building block',
+ door: 'door',
+ wall: 'external wall',
+ cupboard: 'tool cupboard',
+})
+
+// ── Small helpers ──────────────────────────────────────────────────────────
+
+const str = (value) => (value === undefined || value === null || value === '' ? undefined : String(value))
+
+/** `rust::` — readable prefix, bounded length. */
+function dedupeKey(what, ...parts) {
+ const digest = crypto.createHash('sha1').update(parts.map((p) => String(p ?? '')).join('\u0000')).digest('hex')
+ return `rust:${what}:${digest}`
+}
+
+function frameTime(item, frame) {
+ const t = Number(frame && frame.t) || Number(item && item.t)
+ return Number.isFinite(t) && t > 0 ? t : Date.now()
+}
+
+/** D63, as a question: is an event from `t` still worth telling, for this family? */
+function stillNews(t, maxAgeMs, now = Date.now()) {
+ return now - t <= maxAgeMs
+}
+
+function serverVars(server) {
+ const serverId = String(server.id)
+ return { serverId, server: server.name || serverId, serverUrl: serverPath(serverId) }
+}
+
+/**
+ * Hands one event to core. Never throws.
+ *
+ * Core throws on a contract mismatch outside production, which is how a
+ * declaration and an emitter drifting apart is meant to be found. It is logged
+ * at `error` here rather than re-thrown, because the caller is the ingest
+ * cursor — and an `error` line is what a rig walk reads.
+ */
+function fire(triggerId, envelope) {
+ try {
+ core.emit(triggerId, envelope)
+ return true
+ } catch (err) {
+ log.error('core refused an emit', { trigger: triggerId, error: err.message })
+ return false
+ }
+}
+
+/** Steam id -> website user id, for the ids that are linked. Unlinked ones are simply absent. */
+async function usersFor(steamIds) {
+ const ids = [...new Set((steamIds || []).map(String).filter(Boolean))]
+ if (!ids.length) return new Map()
+ const rows = await linksDb.userIdsForSteamIds(ids)
+ return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)]))
+}
+
+// ── Per-kind handlers ──────────────────────────────────────────────────────
+
+/**
+ * The raid alert (D59-D61, D66, D67).
+ *
+ * One emit per authorised, LINKED person, each with `ownerUserId` — so the
+ * `owner` ceiling holds per emit and "nobody else" is structural rather than a
+ * filter somebody could forget. Two Steam accounts held by one website user are
+ * one person: they get one alert, online if either account is.
+ */
+async function onRaid(server, item, frame) {
+ const t = frameTime(item, frame)
+ if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
+
+ // D67: no cupboard, nobody to tell. Absent — not empty — is also what a
+ // protocol-6 plugin sends, so a half-upgraded deployment alerts nobody rather
+ // than guessing an owner from the placer.
+ if (!frame.buildingId || !Array.isArray(frame.authorized)) return 0
+
+ const authorized = frame.authorized.filter((a) => a && a.steamId)
+ const attacker = str(frame.attackerId)
+
+ // An authorised attacker is demolishing their own base, or a teammate's.
+ if (attacker && authorized.some((a) => String(a.steamId) === attacker)) return 0
+
+ const users = await usersFor(authorized.map((a) => a.steamId))
+ if (!users.size) return 0
+
+ const byUser = new Map()
+ for (const a of authorized) {
+ const userId = users.get(String(a.steamId))
+ if (!userId) continue
+ byUser.set(userId, byUser.get(userId) === true || a.online === true)
+ }
+
+ const base = {
+ ...serverVars(server),
+ building: String(frame.buildingId),
+ structure: STRUCTURE_LABELS[frame.structure] || 'structure',
+ grid: str(frame.grid),
+ atGrid: str(frame.grid) ? ` in ${frame.grid}` : undefined,
+ }
+ const key = dedupeKey('raid', server.id, frame.buildingId, t, frame.prefab)
+
+ let sent = 0
+ for (const [userId, online] of byUser) {
+ if (fire(T['rust.base.destroyed'], {
+ data: { ...base, ownerOnline: online },
+ ownerUserId: userId,
+ dedupeKey: key,
+ occurredAt: t,
+ })) sent += 1
+ }
+ return sent
+}
+
+async function onWipe(server, item, frame) {
+ const t = frameTime(item, frame)
+ if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
+ const wipeId = str(frame.wipeId)
+ if (!wipeId) return 0
+
+ return fire(T['rust.wipe.started'], {
+ data: { ...serverVars(server), wipeId },
+ dedupeKey: dedupeKey('wipe', server.id, wipeId),
+ occurredAt: t,
+ }) ? 1 : 0
+}
+
+/**
+ * Clan departures and disbands. Recipients travel on the envelope, because
+ * "the clan this was about" is a different set every firing.
+ *
+ * Nobody is told about what they did themselves: the leaver is not told they
+ * left, the one who kicked is not told they kicked, the one who disbanded is not
+ * told they disbanded. The one KICKED is told — it happened to them.
+ */
+async function onClan(server, item, frame) {
+ const t = frameTime(item, frame)
+ if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
+
+ const externalId = await clans.resolveExternalId(server.id, frame)
+ if (!externalId) return 0
+
+ const kind = frame.kind
+ const subject = str(frame.steamId)
+ let steamIds
+ let actor
+
+ if (kind === 'clan.disbanded') {
+ // From the frame (protocol 7): by the time this runs the next board may
+ // already have removed the roster the store would answer with.
+ steamIds = Array.isArray(frame.members) ? frame.members.map(String) : null
+ if (!steamIds) steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
+ actor = subject
+ } else {
+ steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
+ if (kind === 'clan.member.kicked') {
+ if (subject) steamIds.push(subject)
+ actor = str(frame.bySteamId)
+ } else {
+ actor = subject
+ }
+ }
+
+ const users = await usersFor(steamIds.filter((id) => id !== actor))
+ const recipientUserIds = [...new Set(users.values())]
+ if (!recipientUserIds.length) return 0
+
+ const data = {
+ ...serverVars(server),
+ clanKey: externalId,
+ clan: str(frame.clanName) || 'your clan',
+ clanUrl: clanPath(externalId),
+ }
+ if (kind === 'clan.member.left') data.member = str(frame.name)
+ if (kind === 'clan.member.kicked') {
+ data.member = str(frame.name)
+ data.by = str(frame.byName)
+ }
+ if (kind === 'clan.disbanded') data.by = str(frame.name)
+
+ const triggerId = kind === 'clan.disbanded' ? T['rust.clan.disbanded'] : T[`rust.${kind}`]
+
+ return fire(triggerId, {
+ data,
+ recipientUserIds,
+ dedupeKey: dedupeKey(kind, server.id, externalId, subject, t),
+ occurredAt: t,
+ }) ? 1 : 0
+}
+
+async function onReported(server, item, frame) {
+ const t = frameTime(item, frame)
+ if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
+ const steamId = str(frame.targetId)
+ if (!steamId) return 0
+
+ return fire(T['rust.player.reported'], {
+ data: {
+ ...serverVars(server),
+ steamId,
+ player: str(frame.targetName),
+ reporter: str(frame.reporterName),
+ reportType: str(frame.reportType),
+ topic: str(frame.subject),
+ message: str(frame.message),
+ },
+ dedupeKey: dedupeKey('reported', server.id, steamId, frame.reporterId, t),
+ occurredAt: t,
+ }) ? 1 : 0
+}
+
+async function onBan(server, item, frame) {
+ const t = frameTime(item, frame)
+ if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
+ const steamId = str(frame.steamId)
+ if (!steamId) return 0
+
+ const banned = frame.kind === 'player.banned'
+ const data = { ...serverVars(server), steamId, player: str(frame.name) }
+ // The address the frame carries is deliberately NOT copied: no trigger
+ // declares one, so no template can ever put it in a mail.
+ if (banned) data.reason = str(frame.reason)
+
+ return fire(banned ? T['rust.player.banned'] : T['rust.player.unbanned'], {
+ data,
+ dedupeKey: dedupeKey(frame.kind, server.id, steamId, t),
+ occurredAt: t,
+ }) ? 1 : 0
+}
+
+const HANDLERS = Object.freeze({
+ 'entity.destroyed': onRaid,
+ 'server.wipe': onWipe,
+ 'clan.member.left': onClan,
+ 'clan.member.kicked': onClan,
+ 'clan.disbanded': onClan,
+ 'player.reported': onReported,
+ 'player.banned': onBan,
+ 'player.unbanned': onBan,
+})
+
+/**
+ * One stored frame. Called by ingest BEFORE the frame is applied, because
+ * applying a disband deletes the roster a clan notification is sent to.
+ *
+ * @returns {Promise} emits handed to core, for the log and the tests
+ */
+async function onEvent(server, item) {
+ const frame = (item && item.frame) || {}
+ const kind = (item && item.kind) || frame.kind
+ const handler = HANDLERS[kind]
+ if (!handler || !server) return 0
+
+ try {
+ return await handler(server, item, { ...frame, kind })
+ } catch (err) {
+ log.warn('could not raise a notification', { server: server.id, kind, error: err.message })
+ return 0
+ }
+}
+
+// ── Transitions tracked in memory ──────────────────────────────────────────
+//
+// Deliberately NOT persisted. The question each one answers is "has THIS
+// process seen a previous value", and a value restored from the database would
+// make the first poll after a restart a transition against state the game may
+// have left hours ago.
+
+const tracker = { online: new Map(), leader: new Map() }
+
+/** Forget every tracked value. For the tests. */
+function reset() {
+ tracker.online.clear()
+ tracker.leader.clear()
+}
+
+/**
+ * One poll's verdict on one server (D68): is its game connected now?
+ *
+ * Synchronous and fire-and-forget — the refresh must not wait on core.
+ */
+function serverObserved(server, connected) {
+ try {
+ if (!server) return 0
+ const id = String(server.id)
+ const now = Boolean(connected)
+ const before = tracker.online.get(id)
+ tracker.online.set(id, now)
+
+ // First sight is never a transition: a restart announces nothing.
+ if (before === undefined || before === now) return 0
+
+ return fire(now ? T['rust.server.online'] : T['rust.server.offline'], {
+ data: serverVars(server),
+ // A transition observed by a poll is observed NOW, so it needs no age check;
+ // the key is per minute so that one real flap is one event even if two
+ // polls land either side of a restart of this process.
+ dedupeKey: dedupeKey(now ? 'online' : 'offline', id, Math.floor(Date.now() / 60000)),
+ }) ? 1 : 0
+ } catch (err) {
+ log.warn('could not raise a server transition', { server: server && server.id, error: err.message })
+ return 0
+ }
+}
+
+/**
+ * After a batch: has somebody new taken the lead in this wipe's kills? (D64)
+ *
+ * Only a STRICT lead counts. The leaderboard breaks a tie on who was seen last,
+ * so two players level on kills trade the top row every time either one moves —
+ * and reading the top row alone would announce a new leader each time.
+ */
+async function checkLeader(server) {
+ try {
+ const state = await serversDb.getState(server.id)
+ const wipeId = state && state.wipeId
+ if (!wipeId) return 0
+
+ const rows = await eventsDb.leaderboard({ serverId: server.id, wipeId, sort: 'kills', limit: 2 })
+ const top = rows[0]
+ const kills = top ? Number(top.kills) || 0 : 0
+ const id = String(server.id)
+ const before = tracker.leader.get(id)
+
+ if (!top || kills <= 0) {
+ tracker.leader.set(id, { wipeId, steamId: null })
+ return 0
+ }
+
+ const tied = rows[1] && Number(rows[1].kills) === kills
+ const steamId = String(top.steamId)
+
+ // First sight, or a new wipe: remember, announce nothing.
+ if (!before || before.wipeId !== wipeId) {
+ tracker.leader.set(id, { wipeId, steamId: tied ? null : steamId })
+ return 0
+ }
+
+ if (tied || before.steamId === steamId) return 0
+
+ tracker.leader.set(id, { wipeId, steamId })
+
+ return fire(T['rust.leaderboard.topped'], {
+ data: {
+ ...serverVars(server),
+ leader: str(top.name) || 'A player',
+ kills,
+ leaderboardUrl: leaderboardPath(id),
+ },
+ dedupeKey: dedupeKey('leader', id, wipeId, steamId, kills),
+ }) ? 1 : 0
+ } catch (err) {
+ log.warn('could not check the leaderboard', { server: server && server.id, error: err.message })
+ return 0
+ }
+}
+
+/**
+ * Login attempts that were never approved (D64).
+ *
+ * A query over what is stored rather than a timer per attempt, so a restart
+ * loses nothing and running it twice is a no-op (the key is the attempt's own
+ * server, Steam id and time). Bounded by D63's personal age: an attempt a day
+ * old is not worth a staff mail.
+ */
+async function sweepLoginDenied(servers, now = Date.now()) {
+ let sent = 0
+ for (const server of servers || []) {
+ try {
+ const rows = await eventsDb.unapprovedLogins({
+ serverId: server.id,
+ from: now - PERSONAL_MAX_AGE_MS,
+ to: now - LOGIN_APPROVAL_WINDOW_MS,
+ windowMs: LOGIN_APPROVAL_WINDOW_MS,
+ slackMs: LOGIN_APPROVAL_SLACK_MS,
+ })
+ for (const row of rows) {
+ const t = Number(row.t)
+ if (fire(T['rust.login.denied'], {
+ data: {
+ ...serverVars(server),
+ steamId: String(row.steamId),
+ player: str(row.name),
+ attemptedAt: new Date(t),
+ },
+ dedupeKey: dedupeKey('login-denied', server.id, row.steamId, t),
+ occurredAt: t,
+ })) sent += 1
+ }
+ } catch (err) {
+ log.warn('could not sweep login attempts', { server: server && server.id, error: err.message })
+ }
+ }
+ return sent
+}
+
+/** A Steam account was just linked (R1). Called by the link route, once, on a NEW link. */
+function linked({ userId, steamId, name }) {
+ try {
+ const uid = Number(userId)
+ if (!Number.isInteger(uid) || uid < 1 || !steamId) return 0
+ return fire(T['rust.player.linked'], {
+ data: { steamId: String(steamId), player: str(name), accountUrl: PATHS.account },
+ ownerUserId: uid,
+ dedupeKey: dedupeKey('linked', steamId, uid),
+ }) ? 1 : 0
+ } catch (err) {
+ log.warn('could not raise the link notification', { error: err.message })
+ return 0
+ }
+}
+
+module.exports = {
+ onEvent,
+ serverObserved,
+ checkLeader,
+ sweepLoginDenied,
+ linked,
+ reset,
+ dedupeKey,
+ stillNews,
+ BROADCAST_MAX_AGE_MS,
+ PERSONAL_MAX_AGE_MS,
+ LOGIN_APPROVAL_WINDOW_MS,
+ STRUCTURE_LABELS,
+}
diff --git a/server/engagement/seeds.js b/server/engagement/seeds.js
new file mode 100644
index 0000000..490b1c0
--- /dev/null
+++ b/server/engagement/seeds.js
@@ -0,0 +1,315 @@
+// ── What the notifications read like, and the rules that use them ─────────
+//
+// `registerEngagementSeeds` (MODULE_API.md §2.4 and §1.1 under 1.9.0; PLAN.md
+// §25.2). Data only: nothing here names a recipient, and nothing here turns a
+// rule on.
+//
+// ── Two bespoke bodies, and why only two ──────────────────────────────────
+//
+// A body earns its place when the message has something to say that core's
+// structural projection cannot. The raid alert does — it is the one message
+// here somebody acts on at 3am, and it must say WHERE and WHAT in the first
+// line. The wipe does — it is the one broadcast a whole community waits for.
+// Everything else is "this happened, here is the link", which is exactly what
+// core's `notify.event` / `inapp.event` already say, so it points at those and
+// authors nothing (§4.6.1 property 1).
+//
+// The register is plain, not in-universe. Rust has no court or herald to write
+// in the voice of, and a raid alert dressed as fiction is a raid alert read a
+// second later than it should be.
+//
+// ── Three rules for editing a body ────────────────────────────────────────
+//
+// 1. **No conditionals, and never an optional inside a clause.** An unset
+// optional interpolates to the EMPTY STRING. `atGrid` is a fragment that
+// carries its own leading space for exactly that reason; `grid` on its own
+// belongs on a line of its own or nowhere.
+// 2. **No brand.** `siteName` and friends are supplied by the renderer, so one
+// image mails as whichever site it is running as.
+// 3. **Bump `seedVersion` when a body changes, never for a comment.** It is how
+// a better default reaches deployments whose operators did not edit it.
+//
+// ── One rule group per family ─────────────────────────────────────────────
+//
+// A group is seeded ONCE (per deployment, per key), so a rule appended to a
+// group in a later version reaches fresh installs only. Seven families, seven
+// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
+// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
+// — so installing this module mails nobody until an operator decides it should.
+
+// ── Block helpers ──────────────────────────────────────────────────────────
+
+const text = (id, body, opts = {}) => ({
+ id,
+ type: 'email.text',
+ props: opts.muted ? { text: body, muted: true } : { text: body },
+})
+const heading = (id, body, level = 'h1') => ({ id, type: 'email.heading', props: { level, text: body } })
+const button = (id, label, url, textLead) => ({
+ id,
+ type: 'email.button',
+ props: textLead ? { label, url, textLead } : { label, url },
+})
+const divider = (id) => ({ id, type: 'email.divider', props: {} })
+
+// Every email ends with the unsubscribe pair; `unsubscribeUrl` is core's
+// per-delivery variable, not something a trigger declares.
+const unsubscribe = () => [
+ divider('rule'),
+ button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'),
+]
+
+const email = (key, name, triggerId, subject, blocks) => ({
+ key,
+ name,
+ channel: 'email',
+ triggerId,
+ triggerVersion: 1,
+ seedVersion: 1,
+ subject,
+ blocks: [...blocks, ...unsubscribe()],
+})
+
+/** In-app: heading = the row's title, button = its one action, the rest = its body. */
+const inapp = (key, name, triggerId, title, body, action, url) => ({
+ key,
+ name,
+ channel: 'inapp',
+ triggerId,
+ triggerVersion: 1,
+ seedVersion: 1,
+ subject: null,
+ blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)],
+})
+
+const TEMPLATES = Object.freeze([
+ email(
+ 'rust.base.destroyed',
+ 'Rust — your base was raided',
+ 'rust.base.destroyed',
+ 'Your base on {{server}} is being raided',
+ [
+ heading('h', 'Your base is being raided'),
+ text('p1', 'A {{structure}} of a base you are authorised on was destroyed{{atGrid}} on {{server}}.'),
+ text('p2',
+ 'You are getting this because you are on the base\'s tool cupboard. Further damage to the '
+ + 'same base will not send another alert for a while.', { muted: true }),
+ button('cta', 'Open the server page', '{{serverUrl}}'),
+ ],
+ ),
+ inapp(
+ 'rust.base.destroyed-inapp',
+ 'Rust — your base was raided (in-app)',
+ 'rust.base.destroyed',
+ 'Your base is being raided',
+ 'A {{structure}} was destroyed{{atGrid}} on {{server}}.',
+ 'Open the server',
+ '{{serverUrl}}',
+ ),
+ email(
+ 'rust.wipe.started',
+ 'Rust — a server wiped',
+ 'rust.wipe.started',
+ '{{server}} has wiped',
+ [
+ heading('h', '{{server}} has wiped'),
+ text('p1', 'A new wipe has started on {{server}}: a fresh map, and a fresh start for everyone.'),
+ button('cta', 'Open the server page', '{{serverUrl}}'),
+ ],
+ ),
+ inapp(
+ 'rust.wipe.started-inapp',
+ 'Rust — a server wiped (in-app)',
+ 'rust.wipe.started',
+ '{{server}} has wiped',
+ 'A new wipe has started: a fresh map, and a fresh start for everyone.',
+ 'Open the server',
+ '{{serverUrl}}',
+ ),
+])
+
+// ── The rules — every one of them off ──────────────────────────────────────
+
+/** Core's generic bodies (§4.6.1 property 1). */
+const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' }
+
+/** This module's bodies for a trigger, and core's digest. */
+const bodies = (key) => ({ email: key, inapp: `${key}-inapp`, digest: 'notify.digest' })
+
+const RULE_GROUPS = Object.freeze([
+ {
+ key: 'raid-v1',
+ note: 'module-rust: the raid alert (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.base.destroyed',
+ name: 'Raid alert — offline owners',
+ audience: 'owner',
+ // Push is allowed because this trigger is also a stream (D65); the
+ // tickle carries no content, and the app pulls the inbox row.
+ channels: ['email', 'inapp', 'push'],
+ template_keys: bodies('rust.base.destroyed'),
+ // Per BUILDING (the subjectKey): a raid is dozens of walls and one alert.
+ cooldown_seconds: 1800,
+ max_sends_per_hour: 500,
+ // D61: "offline raid alert" is this condition, not code. An operator who
+ // wants online raids too deletes it.
+ conditions: { variable: 'ownerOnline', cmp: 'eq', value: false },
+ },
+ ],
+ },
+ {
+ key: 'wipe-v1',
+ note: 'module-rust: wipe announcements (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.wipe.started',
+ name: 'Server wiped',
+ audience: 'subscribers',
+ channels: ['email', 'inapp', 'push'],
+ template_keys: bodies('rust.wipe.started'),
+ cooldown_seconds: 6 * 3600,
+ max_sends_per_hour: 2000,
+ },
+ ],
+ },
+ {
+ key: 'server-v1',
+ note: 'module-rust: server up and down (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.server.online',
+ name: 'Server came online',
+ audience: 'subscribers',
+ channels: ['inapp', 'push'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 3600,
+ max_sends_per_hour: 2000,
+ },
+ {
+ trigger_id: 'rust.server.offline',
+ name: 'Server went offline',
+ audience: 'subscribers',
+ channels: ['inapp', 'push'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 3600,
+ max_sends_per_hour: 2000,
+ // A plugin reload, or a restart that is back within five minutes, is not
+ // an outage anybody needs to hear about. `cancel_on` withdraws the
+ // pending notice when the server comes back inside the window.
+ delay_seconds: 300,
+ cancel_on: ['rust.server.online'],
+ },
+ ],
+ },
+ {
+ key: 'leaderboard-v1',
+ note: 'module-rust: a new kills leader (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.leaderboard.topped',
+ name: 'New kills leader',
+ audience: 'subscribers',
+ channels: ['inapp'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 3600,
+ max_sends_per_hour: 2000,
+ },
+ ],
+ },
+ {
+ key: 'account-v1',
+ note: 'module-rust: a Steam account was linked (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.player.linked',
+ name: 'Steam account linked',
+ audience: 'owner',
+ // Email as well as in-app: the case this exists for is a link the person
+ // did NOT make, and they will not be looking at the site's inbox for it.
+ channels: ['email', 'inapp'],
+ template_keys: GENERIC,
+ cooldown_seconds: 0,
+ max_sends_per_hour: 200,
+ },
+ ],
+ },
+ {
+ key: 'clans-v1',
+ note: 'module-rust: clan departures and disbands (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.clan.member.left',
+ name: 'Clan — a member left',
+ audience: 'members',
+ channels: ['inapp'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 0,
+ max_sends_per_hour: 500,
+ },
+ {
+ trigger_id: 'rust.clan.member.kicked',
+ name: 'Clan — a member was removed',
+ audience: 'members',
+ channels: ['inapp'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 0,
+ max_sends_per_hour: 500,
+ },
+ {
+ trigger_id: 'rust.clan.disbanded',
+ name: 'Clan — disbanded',
+ audience: 'members',
+ channels: ['email', 'inapp'],
+ template_keys: GENERIC,
+ cooldown_seconds: 0,
+ max_sends_per_hour: 500,
+ },
+ ],
+ },
+ {
+ key: 'moderation-v1',
+ note: 'module-rust: reports, bans and unapproved logins, to staff (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.player.reported',
+ name: 'Player reported',
+ audience: 'staff',
+ channels: ['email', 'inapp'],
+ template_keys: GENERIC,
+ // Per REPORTED player: a pile-on of ten reports is one notice an hour.
+ cooldown_seconds: 3600,
+ max_sends_per_hour: 200,
+ },
+ {
+ trigger_id: 'rust.player.banned',
+ name: 'Player banned',
+ audience: 'staff',
+ channels: ['inapp'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 0,
+ max_sends_per_hour: 200,
+ },
+ {
+ trigger_id: 'rust.player.unbanned',
+ name: 'Player unbanned',
+ audience: 'staff',
+ channels: ['inapp'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 0,
+ max_sends_per_hour: 200,
+ },
+ {
+ trigger_id: 'rust.login.denied',
+ name: 'Login not approved',
+ audience: 'staff',
+ channels: ['inapp'],
+ template_keys: { inapp: GENERIC.inapp },
+ cooldown_seconds: 3600,
+ max_sends_per_hour: 200,
+ },
+ ],
+ },
+])
+
+module.exports = { TEMPLATES, RULE_GROUPS }
diff --git a/server/engagement/streams.js b/server/engagement/streams.js
new file mode 100644
index 0000000..565a9c5
--- /dev/null
+++ b/server/engagement/streams.js
@@ -0,0 +1,53 @@
+// ── The push facet: which triggers may reach a phone ──────────────────────
+//
+// `registerNotificationStreams` (MODULE_API.md §2.4). A stream is what a device
+// subscribes to, and **core delivers an engagement rule's push only to devices
+// subscribed to a stream whose id IS the trigger id** (`pushChannel.deliver` ->
+// `publishToUsers(row.trigger_id)`). So a trigger with no stream here can never
+// buzz a phone, however its rule is set — which is exactly how the families
+// that should not are kept off it (D65).
+//
+// Every id here is ALSO a trigger in `triggers.js`. That is the one namespace
+// core enforces across both facets: one event, with a payload contract and a
+// subscription toggle, owned by one module. An id that appeared only here would
+// be a toggle nothing could ever fire.
+//
+// **The tickle carries nothing.** A push is `{ stream, ref }` and the app pulls
+// the real item over the authenticated inbox API, so a leaked relay topic says
+// that something happened and not what. That is core's guarantee and it is why
+// a raid alert may be a push at all.
+
+const STREAMS = Object.freeze([
+ {
+ id: 'rust.base.destroyed',
+ label: 'Your base was raided',
+ description: 'Part of a base you are authorised on was destroyed by another player.',
+ // Delivered only to the owner's devices, never fanned out: `owner` ceiling,
+ // one emit per authorised person (D59).
+ personal: true,
+ requiresLinkedAccount: true,
+ },
+ {
+ id: 'rust.server.online',
+ label: 'A server came online',
+ description: 'A Rust server started or came back.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
+ {
+ id: 'rust.server.offline',
+ label: 'A server went offline',
+ description: 'A Rust server stopped or stopped answering.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
+ {
+ id: 'rust.wipe.started',
+ label: 'A server wiped',
+ description: 'A Rust server started a new wipe.',
+ personal: false,
+ requiresLinkedAccount: false,
+ },
+])
+
+module.exports = { STREAMS }
diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js
new file mode 100644
index 0000000..eeb9cdb
--- /dev/null
+++ b/server/engagement/triggers.js
@@ -0,0 +1,354 @@
+// ── What can happen, as core's engagement engine is told it ───────────────
+//
+// The payload contracts behind every notification this module can cause
+// (MODULE_API.md §2.4, `registerEventTriggers`; PLAN.md §25). A trigger says
+// what an event IS, what a template may interpolate, and — the part that is a
+// security boundary — the widest audience a rule on it may EVER be given.
+//
+// ── The ceiling is containment, not size ──────────────────────────────────
+//
+// `owner` is not a small `staff`, and `staff` does not permit `owner`. For the
+// raid alert "one person" is the person whose base it was; for a ban it is
+// nobody outside the staff room. Each ceiling below is chosen against that
+// lattice and not against a ladder, and core refuses a rule that widens one.
+//
+// ── What no variable here carries, on purpose ─────────────────────────────
+//
+// • An IP address. The login and ban frames carry one; the triggers do not,
+// so no template an operator writes can put an address in a mail. The
+// admin feed still shows it, to staff, where it is useful.
+// • The raider (D66). The raid alert says what was destroyed, where and
+// when. Who did it is gameplay intelligence the game does not hand the
+// victim, and a variable that is not declared cannot be interpolated.
+// • A Steam id other than the subject's own.
+//
+// ── Why `subjectKey` is what it is ────────────────────────────────────────
+//
+// Core's cooldown is per (rule, user, subject, channel). So the subject is the
+// thing a recipient should hear about once per cooldown: a BUILDING for a raid
+// (however many walls fall), a SERVER for a broadcast (however often it
+// bounces), a CLAN for a membership change. A subject that changed every firing
+// — a boot id, a timestamp — would make every cooldown a no-op.
+//
+// ── `version` ──────────────────────────────────────────────────────────────
+//
+// The prop-schema version a template records it was authored against. Bump one
+// on a rename or a type change, never for a label.
+
+const ID = 'rust'
+
+/** Site-relative paths, built the way the client registers them. */
+const PATHS = {
+ servers: `/${ID}`,
+ account: `/player/${ID}`,
+}
+
+// A server id is VARCHAR(64) of the operator's choosing, and a clan's
+// `externalId` is `::`. Core validates a `url`
+// variable against a character class with no `:` in it, so every id that goes
+// into a path is percent-encoded — without it the clan link would be dropped at
+// emit in production, silently, for every clan there is.
+const serverPath = (serverId) => `/${ID}/servers/${encodeURIComponent(serverId)}`
+const leaderboardPath = (serverId) => `${serverPath(serverId)}?tab=leaderboard`
+const clanPath = (externalId) => `/${ID}/clans/${encodeURIComponent(externalId)}`
+
+const V1 = 1
+
+// ── Shared variables ───────────────────────────────────────────────────────
+
+const SERVER = [
+ { name: 'serverId', type: 'string', required: true, example: 'main',
+ description: 'The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts.' },
+ { name: 'server', type: 'string', required: true, example: 'Runic Gateway | Main',
+ description: 'The server\'s display name.' },
+ { name: 'serverUrl', type: 'url', required: false, example: '/rust/servers/main',
+ description: 'Site-relative path to the server\'s page.' },
+]
+
+const CLAN = [
+ { name: 'clanKey', type: 'string', required: true, example: 'main:12:1790142840000',
+ description: 'The clan\'s stable identity. The cooldown subject; not meant for display.' },
+ { name: 'clan', type: 'string', required: true, example: 'The Rust Belt',
+ description: 'The clan\'s name.' },
+ { name: 'clanUrl', type: 'url', required: false, example: '/rust/clans/main%3A12%3A1790142840000',
+ description: 'Site-relative path to the clan\'s page.' },
+]
+
+// ── The raid alert ─────────────────────────────────────────────────────────
+
+const RAID = {
+ id: 'rust.base.destroyed',
+ label: 'Your base was raided',
+ description:
+ 'Part of a base you are authorised on was destroyed by another player: a wall, a door, ' +
+ 'an external wall or gate, or the tool cupboard.',
+ kind: 'event',
+ // One per base per cooldown, however many walls fall. The building is the
+ // tool cupboard's id — the game's own answer to "which base is this".
+ subjectKey: 'building',
+ // One emit per authorised, linked person, each with `ownerUserId` set (D59).
+ // `owner` is the ceiling AND the default: there is nobody else this may reach.
+ audience: 'owner',
+ ceiling: 'owner',
+ version: V1,
+ variables: [
+ ...SERVER,
+ { name: 'building', type: 'string', required: true, example: '8113',
+ description: 'The base, as the id of its tool cupboard. The cooldown subject.' },
+ { name: 'structure', type: 'string', required: true, example: 'door',
+ description: 'What was destroyed: "building block", "door", "external wall" or "tool cupboard".' },
+ { name: 'grid', type: 'string', required: false, example: 'H7',
+ description: 'The map grid square. Absent when the server could not work one out.' },
+ // A FRAGMENT, for use inside a sentence. An unset optional interpolates to
+ // the empty string, so "your door in {{grid}} was destroyed" reads "your
+ // door in was destroyed" when the grid is unknown; this carries its own
+ // leading space and vanishes cleanly instead.
+ { name: 'atGrid', type: 'string', required: false, example: ' in H7',
+ description: 'Sentence fragment: " in H7" with its own leading space, or nothing when the grid is unknown.' },
+ { name: 'ownerOnline', type: 'boolean', required: true, example: false,
+ description: 'Whether YOU were online when it happened. The seeded rule alerts only when this is false.' },
+ ],
+}
+
+// ── Server lifecycle ───────────────────────────────────────────────────────
+//
+// `everyone` because a server being up is what a server page already says to
+// anyone. The DEFAULT is `subscribers` — the people who asked — and an operator
+// widens deliberately.
+
+const BROADCASTS = [
+ {
+ id: 'rust.wipe.started',
+ label: 'A server wiped',
+ description: 'A server started a new wipe: a fresh map, and everything built on the old one gone.',
+ kind: 'event',
+ subjectKey: 'serverId',
+ audience: 'subscribers',
+ ceiling: 'everyone',
+ version: V1,
+ variables: [
+ ...SERVER,
+ { name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
+ description: 'The new wipe\'s identity.' },
+ ],
+ },
+ {
+ id: 'rust.server.online',
+ label: 'A server came online',
+ description: 'A server\'s game started, or came back after being unreachable.',
+ kind: 'event',
+ subjectKey: 'serverId',
+ audience: 'subscribers',
+ ceiling: 'everyone',
+ version: V1,
+ variables: [...SERVER],
+ },
+ {
+ id: 'rust.server.offline',
+ label: 'A server went offline',
+ description: 'A server\'s game stopped, crashed, or stopped talking to the website.',
+ kind: 'event',
+ subjectKey: 'serverId',
+ audience: 'subscribers',
+ ceiling: 'everyone',
+ version: V1,
+ variables: [...SERVER],
+ },
+ {
+ id: 'rust.leaderboard.topped',
+ label: 'A new kills leader',
+ description: 'Somebody new leads the current wipe\'s kills on a server.',
+ kind: 'event',
+ subjectKey: 'serverId',
+ audience: 'subscribers',
+ ceiling: 'everyone',
+ version: V1,
+ variables: [
+ ...SERVER,
+ { name: 'leader', type: 'string', required: true, example: 'Marisol',
+ description: 'The new leader\'s in-game name.' },
+ { name: 'kills', type: 'int', required: true, example: 42,
+ description: 'Their kills this wipe.' },
+ { name: 'leaderboardUrl', type: 'url', required: false, example: '/rust/servers/main?tab=leaderboard',
+ description: 'Site-relative path to the server\'s leaderboard.' },
+ ],
+ },
+]
+
+// ── The player's own account ───────────────────────────────────────────────
+
+const ACCOUNT = {
+ id: 'rust.player.linked',
+ label: 'A Steam account was linked',
+ description: 'A Steam account was linked to your website account with an in-game code.',
+ kind: 'event',
+ subjectKey: 'steamId',
+ // PLAN.md §10 said `self`; core has no such ceiling (§25.1). `owner` with the
+ // linking user as `ownerUserId` is the value that exists and means the same.
+ audience: 'owner',
+ ceiling: 'owner',
+ version: V1,
+ variables: [
+ { name: 'steamId', type: 'string', required: true, example: '76561198000000001',
+ description: 'The Steam account that was linked. Also the cooldown subject.' },
+ { name: 'player', type: 'string', required: false, example: 'Marisol',
+ description: 'The in-game name the game reported when it was linked.' },
+ { name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
+ description: 'Site-relative path to your Rust account page.' },
+ ],
+}
+
+// ── Clans ──────────────────────────────────────────────────────────────────
+//
+// `members` ceiling — clan membership is the clan's business (D49). Recipients
+// travel on the envelope as `recipientUserIds`, because "the clan this was
+// about" is a different answer every firing and cannot be a saved audience.
+//
+// No `rust.clan.member.added`: core already fires `team.member.joined` for our
+// clans through the Team sync, and a second trigger would notify twice (D64).
+
+const CLANS = [
+ {
+ id: 'rust.clan.member.left',
+ label: 'Someone left your clan',
+ description: 'A member left a clan you are in.',
+ kind: 'event',
+ subjectKey: 'clanKey',
+ audience: 'members',
+ ceiling: 'members',
+ version: V1,
+ variables: [
+ ...CLAN,
+ ...SERVER,
+ { name: 'member', type: 'string', required: false, example: 'Darrow',
+ description: 'Who left.' },
+ ],
+ },
+ {
+ id: 'rust.clan.member.kicked',
+ label: 'Someone was removed from your clan',
+ description: 'A member was removed from a clan you are in — or you were.',
+ kind: 'event',
+ subjectKey: 'clanKey',
+ audience: 'members',
+ ceiling: 'members',
+ version: V1,
+ variables: [
+ ...CLAN,
+ ...SERVER,
+ { name: 'member', type: 'string', required: false, example: 'Darrow',
+ description: 'Who was removed.' },
+ { name: 'by', type: 'string', required: false, example: 'Marisol',
+ description: 'Who removed them.' },
+ ],
+ },
+ {
+ id: 'rust.clan.disbanded',
+ label: 'Your clan was disbanded',
+ description: 'A clan you were in was disbanded.',
+ kind: 'event',
+ subjectKey: 'clanKey',
+ audience: 'members',
+ ceiling: 'members',
+ version: V1,
+ variables: [
+ ...CLAN,
+ ...SERVER,
+ { name: 'by', type: 'string', required: false, example: 'Marisol',
+ description: 'Who disbanded it.' },
+ ],
+ },
+]
+
+// ── Moderation — staff, and never wider ────────────────────────────────────
+
+const MODERATION = [
+ {
+ id: 'rust.player.reported',
+ label: 'A player was reported',
+ description: 'A player filed an in-game report against another.',
+ kind: 'event',
+ subjectKey: 'steamId',
+ audience: 'staff',
+ ceiling: 'staff',
+ version: V1,
+ variables: [
+ ...SERVER,
+ { name: 'steamId', type: 'string', required: true, example: '76561198000000002',
+ description: 'The reported player\'s Steam id. The cooldown subject.' },
+ { name: 'player', type: 'string', required: false, example: 'Darrow',
+ description: 'The reported player\'s name.' },
+ { name: 'reporter', type: 'string', required: false, example: 'Marisol',
+ description: 'Who filed the report.' },
+ { name: 'reportType', type: 'string', required: false, example: 'cheat',
+ description: 'The category the reporter chose.' },
+ { name: 'topic', type: 'string', required: false, example: 'Aimbot at the dome',
+ description: 'The report\'s subject line.' },
+ { name: 'message', type: 'string', required: false, example: 'Headshots through two walls.',
+ description: 'The report\'s text.' },
+ ],
+ },
+ {
+ id: 'rust.player.banned',
+ label: 'A player was banned',
+ description: 'A player was banned on a server.',
+ kind: 'event',
+ subjectKey: 'steamId',
+ audience: 'staff',
+ ceiling: 'staff',
+ version: V1,
+ variables: [
+ ...SERVER,
+ { name: 'steamId', type: 'string', required: true, example: '76561198000000002',
+ description: 'The banned player\'s Steam id. The cooldown subject.' },
+ { name: 'player', type: 'string', required: false, example: 'Darrow',
+ description: 'The banned player\'s name.' },
+ { name: 'reason', type: 'string', required: false, example: 'Cheating',
+ description: 'The reason given.' },
+ ],
+ },
+ {
+ id: 'rust.player.unbanned',
+ label: 'A player was unbanned',
+ description: 'A ban on a server was lifted.',
+ kind: 'event',
+ subjectKey: 'steamId',
+ audience: 'staff',
+ ceiling: 'staff',
+ version: V1,
+ variables: [
+ ...SERVER,
+ { name: 'steamId', type: 'string', required: true, example: '76561198000000002',
+ description: 'The player\'s Steam id. The cooldown subject.' },
+ { name: 'player', type: 'string', required: false, example: 'Darrow',
+ description: 'The player\'s name.' },
+ ],
+ },
+ {
+ id: 'rust.login.denied',
+ label: 'A login was not approved',
+ description:
+ 'Somebody tried to join a server and was not let in within a minute: a ban, a failed ' +
+ 'authentication, or a player who gave up while connecting.',
+ kind: 'event',
+ subjectKey: 'steamId',
+ audience: 'staff',
+ ceiling: 'staff',
+ version: V1,
+ variables: [
+ ...SERVER,
+ { name: 'steamId', type: 'string', required: true, example: '76561198000000002',
+ description: 'The Steam id that tried to connect. The cooldown subject.' },
+ { name: 'player', type: 'string', required: false, example: 'Darrow',
+ description: 'The name it connected with.' },
+ { name: 'attemptedAt', type: 'datetime', required: true, example: '2026-09-23T03:10:00Z',
+ description: 'When the attempt was made.' },
+ ],
+ },
+]
+
+const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION])
+
+const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
+
+module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath }
diff --git a/server/index.js b/server/index.js
index aff5741..42ee166 100644
--- a/server/index.js
+++ b/server/index.js
@@ -52,6 +52,10 @@ module.exports = function register(ctx, api) {
const adminRust = require('./router/admin/rust.router')
const usersRust = require('./router/admin/usersRust.router')
const teamProvider = require('./model/clans/teamProvider')
+ const { TRIGGERS } = require('./engagement/triggers')
+ const { STREAMS } = require('./engagement/streams')
+ const { AUDIENCES } = require('./engagement/audiences')
+ const seeds = require('./engagement/seeds')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -105,6 +109,31 @@ module.exports = function register(ctx, api) {
// UO + Rust site; it is recorded in §24 rather than worked around here.
api.registerTeamProvider(teamProvider)
+ // Notifications and engagement (R7, PLAN.md §25). Four registrations that are
+ // one decision, because they only mean something together:
+ //
+ // triggers what can happen, what a template may say about it, and the
+ // widest audience a rule on it may EVER have — the security
+ // boundary; core refuses a rule that widens a ceiling
+ // streams which of those may reach a phone. Core pushes an engagement
+ // rule only to devices subscribed to a stream of the SAME id, so
+ // a trigger missing here can never buzz anybody (D65)
+ // audiences named sets of people over this module's data, for an operator
+ // to point a rule at; each answers user ids and nothing else
+ // seeds the two bodies worth writing, and one disabled rule group per
+ // family — installing this module mails nobody
+ //
+ // What fires them is `engagement/emit.js`, off the ingest cursor and the
+ // refresh. Registration is a claim, not a call: nothing here touches the
+ // database, and the seeds are written by core after the schema is up.
+ //
+ // **Not registered, and that is D62:** no announce leg and no post hook. Both
+ // need something in game to deliver to, and phase 10 reaches no game.
+ api.registerEventTriggers(TRIGGERS)
+ api.registerNotificationStreams(STREAMS)
+ api.registerAudiences(AUDIENCES)
+ api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS })
+
// 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.
@@ -117,10 +146,9 @@ module.exports = function register(ctx, api) {
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
- // Everything else this module will register — the event triggers and
- // audiences, the engagement seeds, the four event catalogues, the
- // notification streams and the slash commands — is deliberately absent. Each
- // arrives with the phase that has something real to put in it. A registration
+ // Everything else this module will register — the four event catalogues, the
+ // announce leg 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.
@@ -130,5 +158,8 @@ module.exports = function register(ctx, api) {
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
teams: 'first-party clans',
+ triggers: TRIGGERS.length,
+ streams: STREAMS.length,
+ audiences: AUDIENCES.length,
})
}
diff --git a/server/ingest.js b/server/ingest.js
index 2c33545..41b7de3 100644
--- a/server/ingest.js
+++ b/server/ingest.js
@@ -35,6 +35,7 @@ const core = require('./core')
const clans = require('./model/clans/clans.model')
const db = require('./model/events/events.db')
+const engagement = require('./engagement/emit')
const links = require('./model/links/links.model')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
@@ -64,7 +65,7 @@ const MAX_BATCHES_PER_TICK = 10
* the alternative is losing the one copy of an event the next version will know
* how to read.
*/
-async function apply(serverId, item) {
+async function apply(serverId, item, server = null) {
const frame = (item && item.frame) || {}
const kind = item.kind || frame.kind
const wipeId = frame.wipeId || null
@@ -82,6 +83,12 @@ async function apply(serverId, item) {
raw: frame,
})
+ // What core's engagement engine is told (PLAN.md §25). BEFORE the frame is
+ // applied, because applying a disband deletes the roster the notification is
+ // for. Never throws, and does not hold the cursor on core: `onEvent` resolves
+ // who a frame is about and hands it over, and delivery is core's own time.
+ if (server) await engagement.onEvent(server, item)
+
const at = { serverId, wipeId, steamId: frame.steamId }
switch (kind) {
@@ -257,7 +264,7 @@ async function ingestServer(server) {
for (const item of items) {
try {
- await apply(server.id, item)
+ await apply(server.id, item, server)
applied += 1
} catch (err) {
// One malformed event must not wedge a server's cursor for ever. It is
@@ -284,7 +291,11 @@ async function ingestServer(server) {
if (!res.data.more) break
}
- if (applied > 0) log.info('ingested', { server: server.id, events: applied, cursor: since })
+ if (applied > 0) {
+ log.info('ingested', { server: server.id, events: applied, cursor: since })
+ // A leader can only change when something was applied. Never throws.
+ await engagement.checkLeader(server)
+ }
return applied
}
diff --git a/server/model/clans/clans.model.js b/server/model/clans/clans.model.js
index 47477c4..b40a100 100644
--- a/server/model/clans/clans.model.js
+++ b/server/model/clans/clans.model.js
@@ -563,6 +563,7 @@ module.exports = {
normaliseClan,
applyBoard,
applyEvent,
+ resolveExternalId,
reofferActivity,
activityItem,
dedupeKeyOf,
diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js
index a547660..08f0692 100644
--- a/server/model/events/events.db.js
+++ b/server/model/events/events.db.js
@@ -272,6 +272,38 @@ async function presenceFor(serverId) {
)
}
+/**
+ * Login attempts in `[from, to]` that no approval answered (D64).
+ *
+ * An attempt is answered by a `player.approved` for the same Steam id on the
+ * same server stamped from `slackMs` before it to `windowMs` after it. The
+ * slack is clock grain: both frames come off one game thread, and an approval
+ * stamped a millisecond "early" is still the answer.
+ *
+ * Grouped on (steam id, t) because a cursor replayed after a crash can store the
+ * same attempt twice, and one attempt is one denial however often it was
+ * written down.
+ */
+async function unapprovedLogins({ serverId, from, to, windowMs, slackMs }) {
+ return core.query(
+ `SELECT a.steam_id AS steamId, a.t AS t,
+ MAX(JSON_UNQUOTE(JSON_EXTRACT(a.raw, '$.name'))) AS name
+ FROM ${EVENTS} a
+ WHERE a.server_id = ? AND a.kind = 'player.login.attempt'
+ AND a.steam_id IS NOT NULL AND a.t BETWEEN ? AND ?
+ AND NOT EXISTS (
+ SELECT 1 FROM ${EVENTS} b
+ WHERE b.server_id = a.server_id AND b.kind = 'player.approved'
+ AND b.steam_id = a.steam_id
+ AND b.t BETWEEN a.t - ? AND a.t + ?
+ )
+ GROUP BY a.steam_id, a.t
+ ORDER BY a.t ASC
+ LIMIT 200`,
+ [serverId, from, to, slackMs, windowMs],
+ )
+}
+
module.exports = {
getCursor,
setCursor,
@@ -286,4 +318,5 @@ module.exports = {
leaderboard,
listWipes,
presenceFor,
+ unapprovedLogins,
}
diff --git a/server/model/links/links.db.js b/server/model/links/links.db.js
index b25d33f..613eacf 100644
--- a/server/model/links/links.db.js
+++ b/server/model/links/links.db.js
@@ -148,6 +148,23 @@ async function statsForSteamId(steamId) {
)
}
+/**
+ * Which of these Steam ids are linked, and to whom.
+ *
+ * The one question every notification asks — "who on the website is this
+ * player?" — asked for a set at once, because a raid names a cupboard's whole
+ * authorisation list and a clan event a whole roster. An unlinked id is simply
+ * absent from the answer: there is nobody to tell.
+ */
+async function userIdsForSteamIds(steamIds) {
+ if (!steamIds.length) return []
+ const marks = steamIds.map(() => '?').join(', ')
+ return core.query(
+ `SELECT steam_id AS steamId, user_id AS userId FROM ${LINKS} WHERE steam_id IN (${marks})`,
+ steamIds,
+ )
+}
+
module.exports = {
getBySteamId,
listForUser,
@@ -156,4 +173,5 @@ module.exports = {
removeOwned,
removeBySteamId,
statsForSteamId,
+ userIdsForSteamIds,
}
diff --git a/server/model/links/links.model.js b/server/model/links/links.model.js
index 6359fe9..24fa4dc 100644
--- a/server/model/links/links.model.js
+++ b/server/model/links/links.model.js
@@ -18,6 +18,7 @@
const core = require('../../core')
const db = require('./links.db')
+const engagement = require('../../engagement/emit')
const servers = require('../servers/servers.model')
const sidecar = require('../../sidecarClient')
@@ -150,6 +151,10 @@ async function confirmOne({ server, code, userId }) {
const link = shape(await db.getBySteamId(steamId))
log.info('steam account linked', { steamId, userId, server: server.id })
linksChanged('rust account linked')
+ // Only a NEW link is news. The `already` path above is somebody pressing the
+ // button twice, and telling them twice would make the notice meaningless for
+ // the one case it exists for: a link they did not make.
+ engagement.linked({ userId, steamId, name: frame.name })
return { ok: true, link }
}
diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js
index 76fb8d6..0c4e242 100644
--- a/server/model/servers/servers.db.js
+++ b/server/model/servers/servers.db.js
@@ -146,7 +146,7 @@ async function putState(state) {
`INSERT INTO ${STATE}
(server_id, reachable, online, players, max_players, hostname, level, seed,
world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, IF(?, CURRENT_TIMESTAMP, NULL), CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
@@ -154,10 +154,11 @@ async function putState(state) {
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
protocol = VALUES(protocol),
raw = VALUES(raw),
- -- Only a frame moves this; an unreachable write leaves it alone, which is
- -- what lets a page say how long a server has been down rather than how
- -- recently we failed to reach it.
- last_seen_at = CURRENT_TIMESTAMP,
+ -- Only a CONNECTED game moves this; an unreachable write leaves it alone,
+ -- and so does a board the sidecar kept after the game went away (D68).
+ -- That is what lets a page say how long a server has been down rather
+ -- than how recently we failed to reach it.
+ last_seen_at = IF(?, CURRENT_TIMESTAMP, last_seen_at),
updated_at = CURRENT_TIMESTAMP`,
[
state.serverId,
@@ -174,6 +175,8 @@ async function putState(state) {
state.wipeId || null,
state.protocol === undefined ? null : state.protocol,
state.raw ? JSON.stringify(state.raw) : null,
+ state.seen === false ? 0 : 1,
+ state.seen === false ? 0 : 1,
],
)
}
diff --git a/server/package.json b/server/package.json
index ab6ac27..6e9c59d 100644
--- a/server/package.json
+++ b/server/package.json
@@ -10,7 +10,9 @@
"check:imports": "node scripts/checkImports.js",
"check:bundle": "node scripts/checkBundle.js",
"swagger": "node scripts/swaggerFragment.js",
- "check:swagger": "node scripts/swaggerFragment.js --check"
+ "check:swagger": "node scripts/swaggerFragment.js --check",
+ "engagement:manifest": "node scripts/engagementManifest.js",
+ "check:engagement": "node scripts/engagementManifest.js --check"
},
"engines": {
"node": ">=20"
diff --git a/server/scripts/engagementManifest.js b/server/scripts/engagementManifest.js
new file mode 100644
index 0000000..cfb22ed
--- /dev/null
+++ b/server/scripts/engagementManifest.js
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+//
+// The engagement freeze: every trigger, stream and audience this module
+// declares, and every rule it seeds, as one committed file whose DIFF is the
+// review signal (MODULE_API.md §2.4: "a module ships a prebuilt
+// `engagement-triggers.json` in its bundle").
+//
+// **Why it exists when core never reads it.** A trigger declaration is what an
+// operator's templates interpolate and their rules are written against.
+// Renaming a variable, changing its type or widening a ceiling breaks stored
+// templates and rules — silently, at send time, in a mail somebody already
+// got. Committing the declarations as data turns that edit into a visible diff
+// in the PR that makes it, the same job `routes.manifest.json` does for URLs.
+//
+// **It is generated from the registrations, not from the source files**, by
+// running `register()` against a recording api — so what is frozen is what core
+// would be handed, including anything `index.js` does on the way.
+//
+// **It records `coreApi`, not core's `MODULE_API_VERSION`.** Core's own manifest
+// embeds the API version, and every API bump then makes it stale with no change
+// to a single declaration — which is how it once sat stale for a whole phase.
+// The range this module declares moves only when this module decides it should.
+//
+// Usage (from server/):
+// node scripts/engagementManifest.js write ../engagement-triggers.json
+// node scripts/engagementManifest.js --check exit 1 if the committed file is stale
+
+const fs = require('fs')
+const path = require('path')
+
+const { fakeCtx, fakeApi } = require('../test/_fakes')
+
+const MODULE_ROOT = path.resolve(__dirname, '..', '..')
+const MANIFEST = path.join(MODULE_ROOT, 'engagement-triggers.json')
+
+const COMMENT =
+ 'Generated freeze of module-rust\'s engagement contract (docs/modules/rust/PLAN.md §25). ' +
+ 'Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type ' +
+ 'or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.'
+
+function build() {
+ require('../core')._reset()
+ const api = fakeApi()
+ require('../index')(fakeCtx(), api)
+ const { triggers, streams, audiences, engagementSeeds } = api.record
+ const manifest = require('../../module.json')
+
+ const byId = (a, b) => a.id.localeCompare(b.id)
+
+ return {
+ _comment: COMMENT,
+ coreApi: manifest.coreApi,
+ // Sorted by id: reordering a declaration in the source is not a contract
+ // change and must not produce a diff that looks like one. Variables keep
+ // their DECLARED order, which is the order the template editor shows.
+ triggers: [...triggers].sort(byId).map((t) => ({
+ id: t.id,
+ label: t.label,
+ description: t.description,
+ kind: t.kind,
+ subjectKey: t.subjectKey,
+ audience: t.audience,
+ ceiling: t.ceiling,
+ version: t.version,
+ variables: t.variables.map((v) => ({
+ name: v.name,
+ type: v.type,
+ required: v.required,
+ example: v.example,
+ description: v.description,
+ })),
+ })),
+ streams: [...streams].sort(byId).map((s) => ({
+ id: s.id,
+ label: s.label,
+ personal: s.personal,
+ requiresLinkedAccount: s.requiresLinkedAccount,
+ })),
+ // `resolve` is a function over this module's store and cannot be frozen.
+ // What is frozen is the part an operator's saved rule depends on.
+ audiences: [...audiences].sort(byId).map((a) => ({
+ id: a.id,
+ label: a.label,
+ params: a.params,
+ ceiling: a.ceiling,
+ })),
+ ruleGroups: engagementSeeds.ruleGroups.map((g) => ({
+ key: g.key,
+ rules: g.rules.map((r) => ({
+ trigger_id: r.trigger_id,
+ audience: r.audience,
+ channels: r.channels,
+ template_keys: r.template_keys,
+ conditions: r.conditions === undefined ? null : r.conditions,
+ cooldown_seconds: r.cooldown_seconds,
+ delay_seconds: r.delay_seconds || 0,
+ cancel_on: r.cancel_on || [],
+ })),
+ })),
+ templates: engagementSeeds.templates.map((t) => ({
+ key: t.key,
+ channel: t.channel,
+ triggerId: t.triggerId,
+ seedVersion: t.seedVersion,
+ })),
+ }
+}
+
+function main() {
+ const check = process.argv.includes('--check')
+ const next = `${JSON.stringify(build(), null, 2)}\n`
+
+ if (!check) {
+ fs.writeFileSync(MANIFEST, next)
+ process.stdout.write(`wrote ${path.relative(MODULE_ROOT, MANIFEST)}\n`)
+ return
+ }
+
+ // Line endings normalised, as `swaggerFragment.js` does: a Windows checkout
+ // under `core.autocrlf=true` turns the committed LF blob into CRLF, and a byte
+ // comparison would then call an unchanged file stale on every such machine —
+ // a check that cries wolf is a check nobody reads.
+ const lf = (s) => s.replace(/\r\n/g, '\n')
+ let committed
+ try {
+ committed = fs.readFileSync(MANIFEST, 'utf8')
+ } catch {
+ process.stderr.write('engagement-triggers.json is missing — run `npm run engagement:manifest`\n')
+ process.exit(1)
+ }
+
+ if (lf(committed) !== lf(next)) {
+ process.stderr.write(
+ 'engagement-triggers.json is stale: a trigger, stream, audience or seeded rule changed.\n' +
+ 'Run `npm run engagement:manifest` in server/ and commit the result — and read the diff,\n' +
+ 'because a changed variable or ceiling is a change to every rule an operator has saved.\n',
+ )
+ process.exit(1)
+ }
+
+ process.stdout.write('engagement-triggers.json is current\n')
+}
+
+if (require.main === module) main()
+
+module.exports = { build }
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index 7012dbf..8fffaad 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -52,11 +52,13 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
- * **6 — first-party clans.** Protocol 2 was the read path, 3 the first
+ * **7 — the raid frame.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`), 4 the first that writes to
* the game's permission store, 5 the first that writes to the game HOST'S
* FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
- * built from, and no route at all. The bump lands here in the same change as the emitters,
+ * built from, and no route at all; **7** widens `entity.destroyed` to doors,
+ * walls and the cupboard and names who is authorised there, which is what the
+ * raid alert is sent to (PLAN.md §25). 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
@@ -66,7 +68,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
-const PROTOCOL_VERSION = 6
+const PROTOCOL_VERSION = 7
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
diff --git a/server/test/engagement.test.js b/server/test/engagement.test.js
new file mode 100644
index 0000000..9fa0007
--- /dev/null
+++ b/server/test/engagement.test.js
@@ -0,0 +1,475 @@
+// ── Notifications and engagement (phase 10, PLAN.md §25) ───────────────────
+//
+// The properties this suite holds, each with a failure behind it:
+//
+// • every declaration is one core will accept — a ceiling, a subjectKey that
+// names a declared variable, an example on every variable, a closed type —
+// because core refuses the WHOLE module at boot over one bad declaration;
+// • no trigger declares an address, or the raider (D66);
+// • the raid alert reaches exactly the authorised, linked people, one emit
+// each with `ownerUserId`, and nobody when there is no cupboard (D59, D67);
+// • a replayed event is told only while it is still news (D63);
+// • a clan notice goes to the clan and never to whoever caused it;
+// • a transition is announced once, and a first sighting never;
+// • a tie at the top of the leaderboard is not a new leader;
+// • every seed body interpolates only variables its trigger declares.
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx, spy } = require('./_fakes')
+
+// Core's own check on a `url` variable (utils/engagementEmit.js RELATIVE_URL),
+// copied so a path this module builds is held to the rule it will meet.
+const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
+
+const CEILINGS = ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'admin', 'owner']
+const TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
+
+const SERVER = { id: 'main', name: 'Main' }
+const NOW = Date.now()
+
+/** A fresh ctx, a recording emit, and a link table the tests control. */
+function setup({ links = {}, members = {}, state = null, board = [] } = {}) {
+ require('../core')._reset()
+ const ctx = fakeCtx()
+ require('../core').init(ctx)
+
+ const emit = require('../engagement/emit')
+ emit.reset()
+
+ const linksDb = require('../model/links/links.db')
+ const clansDb = require('../model/clans/clans.db')
+ const clans = require('../model/clans/clans.model')
+ const serversDb = require('../model/servers/servers.db')
+ const eventsDb = require('../model/events/events.db')
+
+ const originals = [
+ [linksDb, { ...linksDb }], [clansDb, { ...clansDb }], [clans, { ...clans }],
+ [serversDb, { ...serversDb }], [eventsDb, { ...eventsDb }],
+ ]
+
+ linksDb.userIdsForSteamIds = async (ids) =>
+ ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
+ clans.resolveExternalId = async (serverId, frame) => (frame.clanId ? `${serverId}:${frame.clanId}:1` : null)
+ clansDb.listMembers = async (externalId) => (members[externalId] || []).map((steamId) => ({ steamId }))
+ serversDb.getState = async () => state
+ eventsDb.leaderboard = async () => board.shift() || []
+
+ const restore = () => {
+ for (const [mod, copy] of originals) Object.assign(mod, copy)
+ }
+
+ return { emit, calls: ctx.events.emit.calls, restore, eventsDb }
+}
+
+const raidFrame = (extra = {}) => ({
+ kind: 'entity.destroyed',
+ t: NOW,
+ ownerId: '100',
+ prefab: 'door.hinged.metal',
+ structure: 'door',
+ attackerId: '900',
+ attackerName: 'Raider',
+ grid: 'H7',
+ buildingId: '8113',
+ authorized: [
+ { steamId: '101', online: false },
+ { steamId: '102', online: true },
+ { steamId: '103', online: false },
+ ],
+ ...extra,
+})
+
+// ── The declarations ───────────────────────────────────────────────────────
+
+test('every trigger is a declaration core will accept', () => {
+ const { TRIGGERS } = require('../engagement/triggers')
+ const ids = new Set()
+
+ for (const t of TRIGGERS) {
+ assert.ok(t.id.startsWith('rust.'), `${t.id} is namespaced`)
+ assert.ok(!ids.has(t.id), `${t.id} is declared once`)
+ ids.add(t.id)
+ assert.ok(CEILINGS.includes(t.ceiling), `${t.id} has a real ceiling (there is no "self")`)
+ assert.ok(CEILINGS.includes(t.audience), `${t.id} has a real default audience`)
+ assert.strictEqual(t.kind, 'event')
+
+ const names = new Set(t.variables.map((v) => v.name))
+ assert.ok(names.has(t.subjectKey), `${t.id}'s subjectKey names a declared variable`)
+
+ for (const v of t.variables) {
+ assert.ok(TYPES.includes(v.type), `${t.id}.${v.name} has a closed type`)
+ assert.ok(v.example !== undefined, `${t.id}.${v.name} has an example`)
+ if (v.type === 'url') assert.match(v.example, RELATIVE_URL, `${t.id}.${v.name}'s example is site-relative`)
+ }
+ }
+})
+
+test('the default audience is never wider than the ceiling', () => {
+ const { TRIGGERS } = require('../engagement/triggers')
+ // The pairs this catalogue uses, each one core's `permits` accepts. A new
+ // pairing is a new line here — decided, not assumed.
+ const ALLOWED = new Set(['everyone>subscribers', 'owner>owner', 'members>members', 'staff>staff'])
+ for (const t of TRIGGERS) {
+ assert.ok(ALLOWED.has(`${t.ceiling}>${t.audience}`), `${t.id}: ${t.audience} under ${t.ceiling}`)
+ }
+})
+
+test('no trigger declares an address, or who raided whom (D66)', () => {
+ const { TRIGGERS } = require('../engagement/triggers')
+ for (const t of TRIGGERS) {
+ for (const v of t.variables) {
+ // By camelCase WORD: `wipeId` contains the letters "ip" and is not one.
+ const words = v.name.split(/(?=[A-Z])/).map((w) => w.toLowerCase())
+ for (const banned of ['ip', 'address', 'attacker', 'raider']) {
+ assert.ok(!words.includes(banned), `${t.id}.${v.name}`)
+ }
+ }
+ }
+})
+
+test('the ceilings are the ones §25.2 decided', () => {
+ const { TRIGGERS } = require('../engagement/triggers')
+ const by = Object.fromEntries(TRIGGERS.map((t) => [t.id, t.ceiling]))
+ assert.strictEqual(by['rust.base.destroyed'], 'owner')
+ assert.strictEqual(by['rust.player.linked'], 'owner')
+ for (const id of ['rust.player.reported', 'rust.player.banned', 'rust.player.unbanned', 'rust.login.denied']) {
+ assert.strictEqual(by[id], 'staff', id)
+ }
+ for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
+ assert.strictEqual(by[id], 'members', id)
+ }
+ // D64: core's team.member.joined already covers it, and kits wait for phase 13.
+ assert.strictEqual(by['rust.clan.member.added'], undefined)
+ assert.strictEqual(by['rust.kit.entitled'], undefined)
+})
+
+test('a clan path survives core\'s url check, colons and all', () => {
+ const { clanPath, serverPath, leaderboardPath } = require('../engagement/triggers')
+ assert.match(clanPath('main:12:1790142840000'), RELATIVE_URL)
+ assert.match(serverPath('eu 2'), RELATIVE_URL)
+ assert.match(leaderboardPath('main'), RELATIVE_URL)
+})
+
+// ── The seeds ──────────────────────────────────────────────────────────────
+
+test('every seeded rule is ours, off, and names bodies that exist', () => {
+ const { TRIGGERS } = require('../engagement/triggers')
+ const { TEMPLATES, RULE_GROUPS } = require('../engagement/seeds')
+ const triggerIds = new Set(TRIGGERS.map((t) => t.id))
+ const own = new Set(TEMPLATES.map((t) => t.key))
+ const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
+ const groupKeys = new Set()
+
+ for (const group of RULE_GROUPS) {
+ assert.ok(!groupKeys.has(group.key), `group ${group.key} is unique`)
+ groupKeys.add(group.key)
+ for (const r of group.rules) {
+ assert.ok(triggerIds.has(r.trigger_id), `${r.trigger_id} is one of ours`)
+ assert.strictEqual(r.enabled, undefined, 'enabled is never a seed parameter')
+ assert.ok(Number.isInteger(r.max_sends_per_hour) && r.max_sends_per_hour >= 1)
+ for (const [slot, key] of Object.entries(r.template_keys)) {
+ assert.ok(own.has(key) || coreKeys.has(key), `${r.trigger_id}: ${key}`)
+ if (slot !== 'digest') assert.ok(r.channels.includes(slot), `${r.trigger_id}: ${slot} is a channel`)
+ }
+ for (const channel of r.channels) {
+ if (channel !== 'push') assert.ok(r.template_keys[channel], `${r.trigger_id}: a body for ${channel}`)
+ }
+ }
+ }
+})
+
+test('push appears only on the rules whose trigger is also a stream (D65)', () => {
+ const { STREAMS } = require('../engagement/streams')
+ const { RULE_GROUPS } = require('../engagement/seeds')
+ const pushable = new Set(STREAMS.map((s) => s.id))
+ for (const group of RULE_GROUPS) {
+ for (const r of group.rules) {
+ if (r.channels.includes('push')) assert.ok(pushable.has(r.trigger_id), r.trigger_id)
+ }
+ }
+})
+
+test('every body interpolates only what its trigger declares', () => {
+ const { TRIGGERS } = require('../engagement/triggers')
+ const { TEMPLATES } = require('../engagement/seeds')
+ const declared = new Map(TRIGGERS.map((t) => [t.id, new Set(t.variables.map((v) => v.name))]))
+ // Core's per-delivery and ambient variables — supplied by the renderer.
+ const ambient = new Set(['unsubscribeUrl', 'siteName', 'siteUrl', 'logoUrl', 'year'])
+
+ for (const t of TEMPLATES) {
+ const vars = declared.get(t.triggerId)
+ assert.ok(vars, `${t.key}'s trigger exists`)
+ const text = JSON.stringify([t.subject, t.blocks])
+ for (const [, name] of text.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)) {
+ assert.ok(vars.has(name) || ambient.has(name), `${t.key} uses {{${name}}}`)
+ }
+ assert.ok(t.key.startsWith('rust.'))
+ if (t.channel === 'email') assert.ok(t.subject)
+ else assert.strictEqual(t.subject, null)
+ }
+})
+
+test('the seeded raid rule is the OFFLINE raid alert, as a condition (D61)', () => {
+ const { RULE_GROUPS } = require('../engagement/seeds')
+ const raid = RULE_GROUPS.find((g) => g.key === 'raid-v1').rules[0]
+ assert.deepStrictEqual(raid.conditions, { variable: 'ownerOnline', cmp: 'eq', value: false })
+ assert.strictEqual(raid.audience, 'owner')
+})
+
+// ── The raid alert ─────────────────────────────────────────────────────────
+
+test('the raid alert reaches each authorised, linked person, and nobody else', async () => {
+ const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
+ try {
+ const sent = await emit.onEvent(SERVER, { id: 1, kind: 'entity.destroyed', frame: raidFrame() })
+ assert.strictEqual(sent, 2)
+ assert.deepStrictEqual(calls.map((c) => c[1].ownerUserId).sort(), [11, 12])
+
+ for (const [trigger, env] of calls) {
+ assert.strictEqual(trigger, 'rust.base.destroyed')
+ assert.strictEqual(env.recipientUserIds, undefined, 'owner-shaped, never a recipient list')
+ assert.strictEqual(env.data.building, '8113')
+ assert.strictEqual(env.data.structure, 'door')
+ assert.strictEqual(env.data.atGrid, ' in H7')
+ assert.ok(!JSON.stringify(env.data).includes('Raider'), 'the raider is never named')
+ assert.ok(!JSON.stringify(env.data).includes('900'))
+ }
+ const online = Object.fromEntries(calls.map((c) => [c[1].ownerUserId, c[1].data.ownerOnline]))
+ assert.deepStrictEqual(online, { 11: false, 12: true })
+ } finally {
+ restore()
+ }
+})
+
+test('two Steam accounts held by one person are one alert, online if either is', async () => {
+ const { emit, calls, restore } = setup({ links: { 101: 11, 102: 11 } })
+ try {
+ await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
+ assert.strictEqual(calls.length, 1)
+ assert.strictEqual(calls[0][1].data.ownerOnline, true)
+ } finally {
+ restore()
+ }
+})
+
+test('an authorised attacker is demolishing their own base: no alert', async () => {
+ const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
+ try {
+ await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ attackerId: '102' }) })
+ assert.strictEqual(calls.length, 0)
+ } finally {
+ restore()
+ }
+})
+
+test('no cupboard, nobody to tell — and a protocol-6 frame is the same (D67)', async () => {
+ const { emit, calls, restore } = setup({ links: { 100: 10, 101: 11 } })
+ try {
+ await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ buildingId: undefined, authorized: undefined }) })
+ // A protocol-6 frame: a BuildingBlock with an owner and no `authorized`.
+ await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: { kind: 'entity.destroyed', t: NOW, ownerId: '100', prefab: 'wall' } })
+ assert.strictEqual(calls.length, 0, 'the placer is never a fallback')
+ } finally {
+ restore()
+ }
+})
+
+test('a replayed raid is still told for a day, and not after (D63)', async () => {
+ const { emit, calls, restore } = setup({ links: { 101: 11 } })
+ try {
+ await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 3 * 3600 * 1000 }) })
+ assert.strictEqual(calls.length, 1)
+ await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 25 * 3600 * 1000 }) })
+ assert.strictEqual(calls.length, 1)
+ } finally {
+ restore()
+ }
+})
+
+test('the same frame replayed carries the same dedupe key, within core\'s bound', async () => {
+ const { emit, calls, restore } = setup({ links: { 101: 11 } })
+ try {
+ const item = { id: 7, kind: 'entity.destroyed', frame: raidFrame() }
+ await emit.onEvent(SERVER, item)
+ await emit.onEvent(SERVER, { ...item, id: 99 })
+ assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'keyed on the event, not the row id')
+ assert.ok(calls[0][1].dedupeKey.length <= 190)
+ } finally {
+ restore()
+ }
+})
+
+// ── Broadcasts ─────────────────────────────────────────────────────────────
+
+test('a wipe is news for fifteen minutes (D63)', async () => {
+ const { emit, calls, restore } = setup()
+ try {
+ await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 60 * 1000, wipeId: 'w2' } })
+ await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 20 * 60 * 1000, wipeId: 'w3' } })
+ assert.strictEqual(calls.length, 1)
+ assert.strictEqual(calls[0][0], 'rust.wipe.started')
+ assert.strictEqual(calls[0][1].data.wipeId, 'w2')
+ } finally {
+ restore()
+ }
+})
+
+test('online and offline are transitions, and a first sighting is not one', () => {
+ const { emit, calls, restore } = setup()
+ try {
+ emit.serverObserved(SERVER, true)
+ emit.serverObserved(SERVER, true)
+ assert.strictEqual(calls.length, 0, 'a restart announces nothing')
+ emit.serverObserved(SERVER, false)
+ emit.serverObserved(SERVER, false)
+ emit.serverObserved(SERVER, true)
+ assert.deepStrictEqual(calls.map((c) => c[0]), ['rust.server.offline', 'rust.server.online'])
+ assert.strictEqual(calls[0][1].data.serverId, 'main')
+ } finally {
+ restore()
+ }
+})
+
+test('a new leader is announced once; a tie is not a new leader', async () => {
+ const row = (steamId, kills) => ({ steamId, name: `P${steamId}`, kills })
+ const { emit, calls, restore } = setup({
+ state: { wipeId: 'w1' },
+ board: [
+ [row('1', 5), row('2', 3)], // first sight: remembered, not announced
+ [row('2', 5), row('1', 5)], // level on kills: not a change
+ [row('2', 6), row('1', 5)], // strictly ahead: announced
+ [row('2', 7), row('1', 5)], // the same leader: nothing
+ ],
+ })
+ try {
+ for (let i = 0; i < 4; i += 1) await emit.checkLeader(SERVER)
+ assert.strictEqual(calls.length, 1)
+ assert.strictEqual(calls[0][0], 'rust.leaderboard.topped')
+ assert.strictEqual(calls[0][1].data.leader, 'P2')
+ assert.strictEqual(calls[0][1].data.kills, 6)
+ } finally {
+ restore()
+ }
+})
+
+// ── Clans ──────────────────────────────────────────────────────────────────
+
+test('a disband goes to the roster on the frame, not to the one who did it', async () => {
+ const { emit, calls, restore } = setup({ links: { 201: 21, 202: 22, 203: 23 } })
+ try {
+ await emit.onEvent(SERVER, {
+ kind: 'clan.disbanded',
+ frame: { kind: 'clan.disbanded', t: NOW, clanId: 4, clanName: 'Belt', steamId: '201', name: 'Boss', members: ['201', '202', '203'] },
+ })
+ assert.strictEqual(calls.length, 1)
+ assert.strictEqual(calls[0][0], 'rust.clan.disbanded')
+ assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [22, 23])
+ assert.strictEqual(calls[0][1].data.by, 'Boss')
+ assert.match(calls[0][1].data.clanUrl, RELATIVE_URL)
+ } finally {
+ restore()
+ }
+})
+
+test('the one kicked is told; the one who kicked is not', async () => {
+ const { emit, calls, restore } = setup({
+ links: { 301: 31, 302: 32, 303: 33 },
+ members: { 'main:5:1': ['301', '302'] }, // the board already dropped 303
+ })
+ try {
+ await emit.onEvent(SERVER, {
+ kind: 'clan.member.kicked',
+ frame: { kind: 'clan.member.kicked', t: NOW, clanId: 5, steamId: '303', name: 'Out', bySteamId: '301', byName: 'Boss' },
+ })
+ assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [32, 33])
+ } finally {
+ restore()
+ }
+})
+
+test('a leaver is not told they left, and a lone leaver tells nobody', async () => {
+ const { emit, calls, restore } = setup({ links: { 401: 41, 402: 42 }, members: { 'main:6:1': ['401', '402'] } })
+ try {
+ await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 6, steamId: '402' } })
+ assert.deepStrictEqual(calls[0][1].recipientUserIds, [41])
+ await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 7, steamId: '402' } })
+ assert.strictEqual(calls.length, 1)
+ } finally {
+ restore()
+ }
+})
+
+// ── Moderation ─────────────────────────────────────────────────────────────
+
+test('a ban never carries the address the frame does', async () => {
+ const { emit, calls, restore } = setup()
+ try {
+ await emit.onEvent(SERVER, {
+ kind: 'player.banned',
+ frame: { kind: 'player.banned', t: NOW, steamId: '555', name: 'Cheater', ip: '203.0.113.9', reason: 'aimbot' },
+ })
+ assert.strictEqual(calls.length, 1)
+ assert.ok(!JSON.stringify(calls[0][1]).includes('203.0.113.9'))
+ assert.strictEqual(calls[0][1].data.reason, 'aimbot')
+ } finally {
+ restore()
+ }
+})
+
+test('an unapproved login becomes a staff notice, keyed on the attempt', async () => {
+ const { emit, calls, restore, eventsDb } = setup()
+ try {
+ const asked = []
+ eventsDb.unapprovedLogins = async (q) => {
+ asked.push(q)
+ return [{ steamId: '777', t: NOW - 120000, name: 'Knocker' }]
+ }
+ const sent = await emit.sweepLoginDenied([SERVER], NOW)
+ await emit.sweepLoginDenied([SERVER], NOW)
+ assert.strictEqual(sent, 1)
+ assert.strictEqual(asked[0].to, NOW - emit.LOGIN_APPROVAL_WINDOW_MS, 'an attempt waits its minute first')
+ assert.strictEqual(calls[0][0], 'rust.login.denied')
+ assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'a second sweep is a no-op in core')
+ } finally {
+ restore()
+ }
+})
+
+// ── The rest ───────────────────────────────────────────────────────────────
+
+test('a new link tells its owner, and only its owner', () => {
+ const { emit, calls, restore } = setup()
+ try {
+ emit.linked({ userId: 5, steamId: '76561198000000001', name: 'Me' })
+ assert.strictEqual(calls[0][0], 'rust.player.linked')
+ assert.strictEqual(calls[0][1].ownerUserId, 5)
+ assert.strictEqual(emit.linked({ userId: 0, steamId: 'x' }), 0)
+ } finally {
+ restore()
+ }
+})
+
+test('a frame the fan-out cannot handle costs one notice, never the caller', async () => {
+ const { emit, restore } = setup()
+ try {
+ const linksDb = require('../model/links/links.db')
+ linksDb.userIdsForSteamIds = async () => { throw new Error('database gone') }
+ const sent = await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
+ assert.strictEqual(sent, 0)
+ } finally {
+ restore()
+ }
+})
+
+test('an audience that fails answers nobody, never everybody', async () => {
+ require('../core')._reset()
+ const ctx = fakeCtx({ db: { query: spy(() => Promise.reject(new Error('down'))), pool: {} } })
+ require('../core').init(ctx)
+ const { AUDIENCES } = require('../engagement/audiences')
+ for (const a of AUDIENCES) {
+ assert.deepStrictEqual(await a.resolve({ clan: 'main:1:1', serverId: 'main' }), [], a.id)
+ assert.deepStrictEqual(await a.resolve({}), [], `${a.id} with no param`)
+ }
+})
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 2204fb2..27e1ff0 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -137,17 +137,36 @@ test('nothing is registered that has nothing behind it yet', () => {
// surfaces an operator can configure and then wait on — worse than an absent
// one, because the absence is visible. Each of these arrives with the phase
// that has something real to put in it, and this assertion is what that phase
- // deletes. Phase 9 deleted the Team provider's line.
- assert.strictEqual(api.record.triggers, null)
- assert.strictEqual(api.record.audiences, null)
- assert.strictEqual(api.record.engagementSeeds, null)
- assert.strictEqual(api.record.streams, null)
+ // deletes. Phase 9 deleted the Team provider's line; phase 10 the four
+ // engagement lines, and the announce leg and post hook it deliberately did
+ // NOT register (D62) moved into the assertions below.
+ assert.deepStrictEqual(api.record.legs, [])
+ assert.strictEqual(api.record.hooks.post, undefined)
assert.strictEqual(api.record.eventBudgets, null)
assert.strictEqual(api.record.eventOptionSources, null)
assert.strictEqual(api.record.eventLeases, null)
assert.strictEqual(api.record.eventActions, null)
})
+test('the engagement set is registered as one decision (phase 10, R7)', () => {
+ const { api } = register()
+
+ const triggers = api.record.triggers
+ const streams = api.record.streams
+ assert.ok(Array.isArray(triggers) && triggers.length > 0)
+ assert.ok(Array.isArray(api.record.audiences) && api.record.audiences.length === 3)
+ assert.ok(api.record.engagementSeeds && Array.isArray(api.record.engagementSeeds.ruleGroups))
+
+ // A stream is a toggle for a trigger; one with no trigger behind it is a
+ // toggle nothing can ever fire (D65, one namespace across both facets).
+ const triggerIds = new Set(triggers.map((t) => t.id))
+ for (const s of streams) assert.ok(triggerIds.has(s.id), `stream ${s.id} has no trigger`)
+ assert.deepStrictEqual(
+ streams.map((s) => s.id).sort(),
+ ['rust.base.destroyed', 'rust.server.offline', 'rust.server.online', 'rust.wipe.started'],
+ )
+})
+
test('the module’s protocol version agrees with the manifest it ships beside', () => {
const sidecar = require('../sidecarClient')
diff --git a/server/test/refresh.test.js b/server/test/refresh.test.js
index 42309c0..4352c51 100644
--- a/server/test/refresh.test.js
+++ b/server/test/refresh.test.js
@@ -91,9 +91,12 @@ test('neither unhappy path calls putState', async () => {
const originalPut = db.putState
const originalMark = db.markUnreachable
const originalBoards = sidecar.boards
+ const originalHealth = sidecar.health
const marked = []
let putCalls = 0
+ sidecar.health = async () => ({ ok: false, status: 0, data: null })
+
db.putState = async () => { putCalls += 1 }
db.markUnreachable = async (id, reachable) => { marked.push([id, reachable]) }
@@ -112,5 +115,83 @@ test('neither unhappy path calls putState', async () => {
db.putState = originalPut
db.markUnreachable = originalMark
sidecar.boards = originalBoards
+ sidecar.health = originalHealth
}
})
+
+test('a board the game left behind is not a game that is up (D68)', async () => {
+ // The sidecar keeps its last `server.hello` after the plugin disconnects, so
+ // until phase 10 a hung game — or an unloaded bridge — with the sidecar still
+ // up read as ONLINE here, with the players it had when it stopped. Only
+ // `/health` knows whether the plugin is connected now.
+ withCore(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
+
+ const db = require('../model/servers/servers.db')
+ const sidecar = require('../sidecarClient')
+ const ingest = require('../ingest')
+ const boot = require('../boot')
+ const engagement = require('../engagement/emit')
+
+ const saved = { put: db.putState, boards: sidecar.boards, health: sidecar.health, apply: ingest.applyBoards }
+ const puts = []
+ const applied = []
+ engagement.reset()
+
+ db.putState = async (state) => { puts.push(state) }
+ ingest.applyBoards = async (id, boards) => { applied.push(boards) }
+ sidecar.boards = async () => ({
+ ok: true,
+ status: 200,
+ data: { boards: {
+ 'server.hello': { players: 12, maxPlayers: 100, hostname: 'Main' },
+ 'players.online': { players: [{ steamId: '1', name: 'Still here?' }] },
+ } },
+ })
+
+ const server = { id: 'main', name: 'Main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 7 }
+
+ try {
+ sidecar.health = async () => ({ ok: true, status: 200, data: { plugin_connected: false } })
+ await boot.refreshOne(server)
+
+ assert.strictEqual(puts[0].online, false)
+ assert.strictEqual(puts[0].players, 0, 'the last count is not a count')
+ assert.strictEqual(puts[0].seen, false, 'a stale board must not move "last seen"')
+ assert.strictEqual(puts[0].hostname, 'Main', 'the description is still written')
+ assert.deepStrictEqual(applied[0]['players.online'].players, [], 'nobody is named as online')
+
+ sidecar.health = async () => ({ ok: true, status: 200, data: { plugin_connected: true } })
+ await boot.refreshOne(server)
+ assert.strictEqual(puts[1].online, true)
+ assert.strictEqual(puts[1].players, 12)
+ assert.notStrictEqual(puts[1].seen, false)
+ assert.strictEqual(applied[1]['players.online'].players.length, 1)
+
+ // An unanswered /health is unknown, and unknown is not up.
+ sidecar.health = async () => ({ ok: false, status: 0, data: null })
+ await boot.refreshOne(server)
+ assert.strictEqual(puts[2].online, false)
+ } finally {
+ db.putState = saved.put
+ sidecar.boards = saved.boards
+ sidecar.health = saved.health
+ ingest.applyBoards = saved.apply
+ engagement.reset()
+ }
+})
+
+test('putState moves last_seen_at only for a game that was seen', async () => {
+ const queries = []
+ withCore(fakeCtx({
+ db: { query: (sql, params) => { queries.push({ sql, params }); return Promise.resolve([]) }, pool: {} },
+ }))
+ const db = require('../model/servers/servers.db')
+
+ await db.putState({ serverId: 'main', reachable: true, online: false, seen: false })
+ await db.putState({ serverId: 'main', reachable: true, online: true })
+
+ // The two trailing parameters feed the two IF(?, CURRENT_TIMESTAMP, …)s.
+ assert.deepStrictEqual(queries[0].params.slice(-2), [0, 0])
+ assert.deepStrictEqual(queries[1].params.slice(-2), [1, 1])
+ assert.strictEqual((queries[0].sql.match(/\?/g) || []).length, queries[0].params.length, 'every placeholder has a value')
+})
--
2.49.1
From 648d3fd2e1af78fdcf1a7620580792caa4181492 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 23 Sep 2026 13:37:05 -0500
Subject: [PATCH 13/51] fix(rust): every notice says which server, clan or
player it is about
The live walk rendered a generic in-app notice as "A server came online.
A server's game started..." Core's structural projection falls back to
the trigger's label and description when the payload has no title, and
on a multi-server site that never says which server. Core's rule is that
the payload wins, so every trigger now declares `title` and `intro`, and
the emitter writes the sentence ("Oxide rig is online"). An operator's
own template can still ignore it and use the parts.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
engagement-triggers.json | 182 +++++++++++++++++++++++++++++++++
server/engagement/emit.js | 75 +++++++++++++-
server/engagement/triggers.js | 31 +++++-
server/test/engagement.test.js | 17 +++
4 files changed, 302 insertions(+), 3 deletions(-)
diff --git a/engagement-triggers.json b/engagement-triggers.json
index bc2b357..859e228 100644
--- a/engagement-triggers.json
+++ b/engagement-triggers.json
@@ -33,6 +33,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "building",
"type": "string",
@@ -122,6 +136,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "by",
"type": "string",
@@ -183,6 +211,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "member",
"type": "string",
@@ -251,6 +293,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "member",
"type": "string",
@@ -291,6 +347,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "leader",
"type": "string",
@@ -345,6 +415,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "steamId",
"type": "string",
@@ -399,6 +483,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "steamId",
"type": "string",
@@ -432,6 +530,20 @@
"ceiling": "owner",
"version": 1,
"variables": [
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "steamId",
"type": "string",
@@ -486,6 +598,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "steamId",
"type": "string",
@@ -561,6 +687,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "steamId",
"type": "string",
@@ -607,6 +747,20 @@
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
}
]
},
@@ -640,6 +794,20 @@
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
}
]
},
@@ -674,6 +842,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
{
"name": "wipeId",
"type": "string",
diff --git a/server/engagement/emit.js b/server/engagement/emit.js
index 89acc9c..9021220 100644
--- a/server/engagement/emit.js
+++ b/server/engagement/emit.js
@@ -94,6 +94,77 @@ function serverVars(server) {
return { serverId, server: server.name || serverId, serverUrl: serverPath(serverId) }
}
+/**
+ * The headline every trigger carries (`triggers.js` HEADLINE).
+ *
+ * Core's generic bodies fall back to a trigger's LABEL and DESCRIPTION when the
+ * payload has no `title`/`intro`, and on a multi-server site that fallback says
+ * "A server came online" without ever saying which. So the sentence is written
+ * here, from the payload, and core renders it. Plain register, no conditionals:
+ * a missing part falls back to a neutral word rather than leaving a hole.
+ */
+const HEADLINES = Object.freeze({
+ 'rust.base.destroyed': (d) => ({
+ title: `Your base on ${d.server} is being raided`,
+ intro: `A ${d.structure} was destroyed${d.atGrid || ''} on ${d.server}.`,
+ }),
+ 'rust.wipe.started': (d) => ({
+ title: `${d.server} has wiped`,
+ intro: `A new wipe has started on ${d.server}: a fresh map, and a fresh start for everyone.`,
+ }),
+ 'rust.server.online': (d) => ({
+ title: `${d.server} is online`,
+ intro: `${d.server} is back up and talking to the website.`,
+ }),
+ 'rust.server.offline': (d) => ({
+ title: `${d.server} is offline`,
+ intro: `${d.server} stopped, or stopped talking to the website.`,
+ }),
+ 'rust.leaderboard.topped': (d) => ({
+ title: `${d.leader} leads ${d.server}`,
+ intro: `${d.leader} now leads this wipe's kills on ${d.server}, with ${d.kills}.`,
+ }),
+ 'rust.player.linked': (d) => ({
+ title: 'A Steam account was linked to your account',
+ intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
+ + 'If that was not you, unlink it from your Rust account page.',
+ }),
+ 'rust.clan.member.left': (d) => ({
+ title: `${d.member || 'A member'} left ${d.clan}`,
+ intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
+ }),
+ 'rust.clan.member.kicked': (d) => ({
+ title: `${d.member || 'A member'} was removed from ${d.clan}`,
+ intro: `${d.by || 'A clan leader'} removed ${d.member || 'a member'} from ${d.clan} on ${d.server}.`,
+ }),
+ 'rust.clan.disbanded': (d) => ({
+ title: `${d.clan} was disbanded`,
+ intro: `${d.by || 'A clan leader'} disbanded ${d.clan} on ${d.server}.`,
+ }),
+ 'rust.player.reported': (d) => ({
+ title: `${d.player || d.steamId} was reported on ${d.server}`,
+ intro: `${d.reporter || 'A player'} reported ${d.player || d.steamId}`
+ + `${d.reportType ? ` (${d.reportType})` : ''}${d.topic ? `: ${d.topic}` : '.'}`,
+ }),
+ 'rust.player.banned': (d) => ({
+ title: `${d.player || d.steamId} was banned on ${d.server}`,
+ intro: d.reason ? `Reason given: ${d.reason}` : 'No reason was given.',
+ }),
+ 'rust.player.unbanned': (d) => ({
+ title: `${d.player || d.steamId} was unbanned on ${d.server}`,
+ intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
+ }),
+ 'rust.login.denied': (d) => ({
+ title: `A login to ${d.server} was not approved`,
+ intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
+ }),
+})
+
+function headline(triggerId, data) {
+ const make = HEADLINES[triggerId]
+ return make ? make(data || {}) : {}
+}
+
/**
* Hands one event to core. Never throws.
*
@@ -104,7 +175,8 @@ function serverVars(server) {
*/
function fire(triggerId, envelope) {
try {
- core.emit(triggerId, envelope)
+ const data = envelope.data || {}
+ core.emit(triggerId, { ...envelope, data: { ...headline(triggerId, data), ...data } })
return true
} catch (err) {
log.error('core refused an emit', { trigger: triggerId, error: err.message })
@@ -482,6 +554,7 @@ module.exports = {
linked,
reset,
dedupeKey,
+ headline,
stillNews,
BROADCAST_MAX_AGE_MS,
PERSONAL_MAX_AGE_MS,
diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js
index eeb9cdb..ea8485c 100644
--- a/server/engagement/triggers.js
+++ b/server/engagement/triggers.js
@@ -56,6 +56,22 @@ const V1 = 1
// ── Shared variables ───────────────────────────────────────────────────────
+// **Every trigger carries its own headline.** Most rules here point at core's
+// generic `notify.event` / `inapp.event`, and core's structural projection fills
+// `title` and `intro` from the trigger's LABEL and DESCRIPTION only when the
+// payload does not define them — "the payload wins, the projection fills gaps"
+// (ENGAGEMENT.md §4.6.1). Without these two, the phase-10 walk rendered a
+// multi-server site's notice as "A server came online. A server's game
+// started…" — true, and useless, because it never said which. The emitter
+// writes the sentence (`emit.js` `headline`); an operator's own template can
+// still ignore it and interpolate the parts.
+const HEADLINE = [
+ { name: 'title', type: 'string', required: false, example: 'Main is back online',
+ description: 'A one-line headline naming what happened and where. Core generic bodies use it as the title.' },
+ { name: 'intro', type: 'string', required: false, example: 'Main is back up and taking players.',
+ description: 'One sentence of detail. Core generic bodies use it as the body.' },
+]
+
const SERVER = [
{ name: 'serverId', type: 'string', required: true, example: 'main',
description: 'The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts.' },
@@ -93,6 +109,7 @@ const RAID = {
version: V1,
variables: [
...SERVER,
+ ...HEADLINE,
{ name: 'building', type: 'string', required: true, example: '8113',
description: 'The base, as the id of its tool cupboard. The cooldown subject.' },
{ name: 'structure', type: 'string', required: true, example: 'door',
@@ -128,6 +145,7 @@ const BROADCASTS = [
version: V1,
variables: [
...SERVER,
+ ...HEADLINE,
{ name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
description: 'The new wipe\'s identity.' },
],
@@ -141,7 +159,7 @@ const BROADCASTS = [
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
- variables: [...SERVER],
+ variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.server.offline',
@@ -152,7 +170,7 @@ const BROADCASTS = [
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
- variables: [...SERVER],
+ variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.leaderboard.topped',
@@ -165,6 +183,7 @@ const BROADCASTS = [
version: V1,
variables: [
...SERVER,
+ ...HEADLINE,
{ name: 'leader', type: 'string', required: true, example: 'Marisol',
description: 'The new leader\'s in-game name.' },
{ name: 'kills', type: 'int', required: true, example: 42,
@@ -189,6 +208,7 @@ const ACCOUNT = {
ceiling: 'owner',
version: V1,
variables: [
+ ...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000001',
description: 'The Steam account that was linked. Also the cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Marisol',
@@ -220,6 +240,7 @@ const CLANS = [
variables: [
...CLAN,
...SERVER,
+ ...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who left.' },
],
@@ -236,6 +257,7 @@ const CLANS = [
variables: [
...CLAN,
...SERVER,
+ ...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who was removed.' },
{ name: 'by', type: 'string', required: false, example: 'Marisol',
@@ -254,6 +276,7 @@ const CLANS = [
variables: [
...CLAN,
...SERVER,
+ ...HEADLINE,
{ name: 'by', type: 'string', required: false, example: 'Marisol',
description: 'Who disbanded it.' },
],
@@ -274,6 +297,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
+ ...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The reported player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
@@ -299,6 +323,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
+ ...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The banned player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
@@ -318,6 +343,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
+ ...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
@@ -337,6 +363,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
+ ...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The Steam id that tried to connect. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
diff --git a/server/test/engagement.test.js b/server/test/engagement.test.js
index 9fa0007..6445e01 100644
--- a/server/test/engagement.test.js
+++ b/server/test/engagement.test.js
@@ -152,6 +152,23 @@ test('a clan path survives core\'s url check, colons and all', () => {
assert.match(leaderboardPath('main'), RELATIVE_URL)
})
+test('every trigger names what happened and where, even with its optionals missing', () => {
+ // The walk found a multi-server site's generic notice reading "A server came
+ // online" — core's projection falls back to the LABEL when the payload carries
+ // no `title`. Every trigger therefore declares its own, and the emitter writes
+ // it; a headline that printed "undefined" would be worse than the label.
+ const { TRIGGERS } = require('../engagement/triggers')
+ const { headline } = require('../engagement/emit')
+ for (const t of TRIGGERS) {
+ const minimal = Object.fromEntries(t.variables.filter((v) => v.required).map((v) => [v.name, v.example]))
+ const h = headline(t.id, minimal)
+ assert.ok(h.title && h.intro, `${t.id} has a headline`)
+ assert.ok(!/undefined|null/.test(h.title + h.intro), `${t.id}: ${h.title} / ${h.intro}`)
+ }
+ const online = headline('rust.server.online', { server: 'EU 2' })
+ assert.match(online.title, /EU 2/, 'the notice says WHICH server')
+})
+
// ── The seeds ──────────────────────────────────────────────────────────────
test('every seeded rule is ours, off, and names bodies that exist', () => {
--
2.49.1
From d973db7a434089d4ab493fcf7a593d3a05178248 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 23 Sep 2026 21:14:06 -0500
Subject: [PATCH 14/51] feat(rust): the leases and their option sources (phase
12, protocol 8)
Four leases on core.lease: rust.decay.scale, rust.population, rust.spawn.scalar and rust.group.permission. Every lease is targeted and the target names the server (D73). Also the three target option sources plus rust.options.servers, with no budgets (D79). Held for up to seven days (D77).
A key that is already held reads as its baseline. Drift is an answer, not a failure. inForce reads the plugin's holds and never compares values. Lease calls get a 4.5s timeout so that two of them fit in core.lease's 10s budget, and a timed-out apply is followed by a release.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
ci/bundle.json | 1 +
server/eventLeases.js | 434 +++++++++++++++++++++++++++++++++++++
server/index.js | 19 +-
server/sidecarClient.js | 70 +++++-
server/test/entry.test.js | 29 ++-
server/test/leases.test.js | 329 ++++++++++++++++++++++++++++
6 files changed, 872 insertions(+), 10 deletions(-)
create mode 100644 server/eventLeases.js
create mode 100644 server/test/leases.test.js
diff --git a/ci/bundle.json b/ci/bundle.json
index ce75ece..5e8b335 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -33,6 +33,7 @@
"core.js",
"db",
"engagement",
+ "eventLeases.js",
"index.js",
"ingest.js",
"model",
diff --git a/server/eventLeases.js b/server/eventLeases.js
new file mode 100644
index 0000000..7d18d5f
--- /dev/null
+++ b/server/eventLeases.js
@@ -0,0 +1,434 @@
+// ── What an event may BORROW on a Rust server (PLAN.md §27, protocol 8) ─────
+//
+// The module never takes a lease and never bounds one. An author puts core's
+// `core.lease` in a step naming a lease, a target, a value and a number of
+// minutes; core reads the baseline, reserves `#` against the
+// two-events-one-target index, applies the value with its deadline and restores
+// it at teardown. What is here is the four callables each lease ships, and the
+// option sources that fill its target field.
+//
+// ── The target names the server (D73) ─────────────────────────────────────
+//
+// `core.lease` hands a lease only `{ target }` — never the run's scope — and
+// reserves `#`. So every lease here is TARGETED and every target
+// begins with the server id: `srv-a` for a single value, `srv-a/bear.population`
+// or `srv-a/default/kits.vip` for a family. That makes the ledger's unique index
+// bite at exactly the granularity Rust has: two runs on two servers never
+// collide, and one value on one server has one holder.
+//
+// ── Game convars only (D74) ───────────────────────────────────────────────
+//
+// Vanilla Rust has no gather, craft or smelt rate convar; what it has, and what
+// the plugin's allowlist lends, is decay, the population system, and its two
+// minimum scalars — plus a group's permissions, the "weekend VIP" (D75). The
+// plugin holds the allowlist, the bounds, the seven-day ceiling and the deadline
+// timer. The bounds are declared here AS WELL, because this pair is what core
+// checks when an author saves — a bad value is a refusal on a form rather than a
+// step failing unattended at four in the morning.
+
+const core = require('./core')
+const client = require('./sidecarClient')
+const serversDb = require('./model/servers/servers.db')
+const servers = require('./model/servers/servers.model')
+
+const log = core.logger('leases')
+
+/** Seven days (D77). The plugin holds the same ceiling independently and refuses past it. */
+const MAX_LEASE_MS = 7 * 24 * 60 * 60 * 1000
+
+/** Core's bound on one option source's answer. A source that would exceed it says so in the log. */
+const MAX_OPTIONS = 2000
+
+/** The wire key of the one lease that is not a convar. */
+const GROUP_PERMISSION_KEY = 'group.permission'
+
+/**
+ * Split a target into its server and the rest (D73).
+ *
+ * At the FIRST slash: a server id is `[a-z0-9-]` and never contains one, while
+ * what follows may (a group name is free text an operator typed).
+ */
+function splitTarget(target) {
+ const text = String(target || '').trim()
+ const slash = text.indexOf('/')
+ if (slash < 0) return { serverId: text, rest: '' }
+ return { serverId: text.slice(0, slash), rest: text.slice(slash + 1) }
+}
+
+/**
+ * The server a target names, with its token — or a refusal.
+ *
+ * **`retry: false`**, because the second attempt carries the same params: a
+ * target naming a server that is not configured (or is switched off) is an
+ * authoring mistake or a deleted server, and neither is fixed by waiting.
+ */
+async function serverFor(serverId) {
+ if (!serverId) return { ok: false, retry: false, error: 'the target does not name a server' }
+ const row = await serversDb.getServer(serverId)
+ if (!row) return { ok: false, retry: false, error: `there is no Rust server "${serverId}" on this site` }
+ if (!row.enabled) return { ok: false, retry: false, error: `the Rust server "${row.name || serverId}" is switched off` }
+ return { ok: true, server: servers.withToken(row) }
+}
+
+/** The sentence for a transport failure, naming the server — every notice says which (§25.6). */
+function transportError(server, result, what) {
+ const name = (server && (server.name || server.id)) || 'the server'
+ switch (result.status) {
+ case 'http-503':
+ return `${name} has no game connected, so its ${what} could not be reached`
+ case 'http-504':
+ case 'timeout':
+ return `${name} did not answer about its ${what} in time`
+ case 'protocol-mismatch':
+ return `${name}'s sidecar speaks a different protocol — update the module or the sidecar`
+ default:
+ return `${name} could not be reached about its ${what} (${result.status})`
+ }
+}
+
+/** A plugin's own refusal, which carries a sentence of its own. */
+function pluginError(data, fallback) {
+ return (data && (data.message || data.reason)) || fallback
+}
+
+/**
+ * Build the four callables one lease shares with every other.
+ *
+ * `wire(rest)` turns what follows the server id into the plugin's `{ key,
+ * target }`, or a refusal. The callables differ in nothing else, so they are
+ * built rather than repeated: four copies of this would be four chances for one
+ * of them to forget the drift check, which is the one thing §F says a lease must
+ * not be allowed to skip.
+ */
+function lease({ id, label, description, type, min, max, family, targetLabel, source, example, wire }) {
+ async function resolve(target) {
+ const { serverId, rest } = splitTarget(target)
+ const found = await serverFor(serverId)
+ if (!found.ok) return found
+ const w = wire(rest)
+ if (!w.ok) return { ok: false, retry: false, error: w.error }
+ return { ok: true, server: found.server, key: w.key, target: w.target || undefined }
+ }
+
+ /** The plugin's row for this key and target, or a refusal. */
+ async function row(r) {
+ const result = await client.leaseList(r.server, { key: r.key, target: r.target })
+ if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
+ const rows = (result.data && result.data.leases) || []
+ const found = rows.find((x) => x && x.key === r.key && (r.target === undefined || x.target === r.target))
+ if (!found) return { ok: false, retry: false, error: `${r.server.name || r.server.id} does not lend ${r.key}` }
+ if (family && found.family !== family) {
+ return { ok: false, retry: false, error: `${r.key} is not a ${family} value` }
+ }
+ return { ok: true, row: found, data: result.data }
+ }
+
+ return {
+ id,
+ label,
+ description,
+ type,
+ ...(min === undefined ? {} : { min }),
+ ...(max === undefined ? {} : { max }),
+ maxDurationMs: MAX_LEASE_MS,
+ target: { label: targetLabel, source, example },
+
+ async read({ target } = {}) {
+ const r = await resolve(target)
+ if (!r.ok) return r
+ const found = await row(r)
+ if (!found.ok) return found
+
+ // **A key the plugin already holds reads as its BASELINE, not its
+ // current value.** Core's reservation means a second run can never get
+ // this far, so a hold core does not know about is the first attempt of
+ // THIS run whose answer was lost — and the baseline to give back at the
+ // end is what was there before anybody borrowed it, not that attempt's
+ // value. Recording the current value here would restore the event's own
+ // change at teardown and call it baseline.
+ if (found.row.held && found.row.baseline !== undefined && found.row.baseline !== null) {
+ return { ok: true, value: String(found.row.baseline) }
+ }
+
+ if (found.row.unreadable) return { ok: false, retry: false, error: found.row.unreadable }
+ if (found.row.current === undefined || found.row.current === null) {
+ return { ok: false, error: `${r.server.name || r.server.id} could not read ${r.key}` }
+ }
+ return { ok: true, value: String(found.row.current) }
+ },
+
+ async apply(value, until, { target } = {}) {
+ const r = await resolve(target)
+ if (!r.ok) return r
+
+ // **A duration, not the deadline.** `until` is an absolute time computed
+ // here and honoured there, which is a deadline measured against two
+ // clocks; a game host ten minutes fast would end a ten-minute lease the
+ // instant it took it. The absolute time still rides along, for display.
+ const untilMs = new Date(until).getTime()
+ const holdMs = untilMs - Date.now()
+ if (!Number.isFinite(holdMs) || holdMs <= 0) {
+ return { ok: false, error: 'the lease deadline has already passed' }
+ }
+
+ const body = {
+ key: r.key,
+ ...(r.target === undefined ? {} : { target: r.target }),
+ ...(family ? { family } : {}),
+ value: String(value),
+ holdMs: Math.round(holdMs),
+ untilMs,
+ }
+
+ const result = await client.leaseApply(r.server, body)
+
+ if (!result.ok) {
+ // **An apply this end gave up on may still land.** The client's lease
+ // timeout is below the sidecar's own, so the command can still reach
+ // the game after core has been told it failed — and core then releases
+ // its reservation, believing nothing was taken. A release follows it
+ // down the same link, which the plugin handles in order: if the apply
+ // landed, the hold's own baseline goes back; if it never did, the
+ // compare finds nothing held and changes nothing. Not awaited: its
+ // answer changes nothing about this one.
+ if (result.status === 'timeout' || result.status === 'http-504') {
+ client
+ .leaseRelease(r.server, { key: r.key, target: r.target, expected: String(value) })
+ .catch(() => {})
+ }
+ return { ok: false, error: transportError(r.server, result, 'lease') }
+ }
+
+ const data = result.data || {}
+ if (data.kind === 'lease.ok') return { ok: true }
+
+ // A refusal the second attempt would repeat is `retry: false` — the
+ // switch is off, the key is not lent, the value is out of range. One that
+ // might pass later (a value the game could not read this second) is left
+ // to core's default.
+ const permanent = ['events-disabled', 'unknown-key', 'out-of-range', 'too-long', 'unresolved', 'target-gone', 'malformed']
+ return {
+ ok: false,
+ ...(permanent.includes(data.reason) ? { retry: false } : {}),
+ error: pluginError(data, `${r.server.name || r.server.id} refused the lease`),
+ }
+ },
+
+ async restore(baseline, { expected, target } = {}) {
+ const r = await resolve(target)
+ if (!r.ok) return r
+
+ const result = await client.leaseRelease(r.server, {
+ key: r.key,
+ ...(r.target === undefined ? {} : { target: r.target }),
+ expected: expected === undefined || expected === null ? undefined : String(expected),
+ baseline: baseline === undefined || baseline === null ? undefined : String(baseline),
+ })
+
+ if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease release') }
+
+ const data = result.data || {}
+
+ // **Drift is a 200 carrying `lease.drifted`, not a failure of the call.**
+ // The plugin did what it was asked: it compared, and declined to
+ // overwrite somebody's deliberate change. Core records that as its own
+ // outcome, with the current value beside it.
+ if (data.kind === 'lease.drifted') return { ok: false, drifted: true, current: data.current }
+
+ // A group deleted mid-hold has nothing to give back and nothing owed: a
+ // successful release, not a failure that would leave a ledger row
+ // unresolved for ever over something that is gone.
+ if (data.kind === 'lease.ok') return { ok: true }
+
+ return { ok: false, error: pluginError(data, `${r.server.name || r.server.id} could not give ${r.key} back`) }
+ },
+
+ /**
+ * Whether the plugin still has a record of the hold.
+ *
+ * **Never a comparison with `read()`** (MODULE_API §1.1). A value that
+ * differs from what the run applied is DRIFT, which `restore()` reports so
+ * the row lands `drifted`; answering "not in force" here would orphan the
+ * row first. A convar hold is memory-only on the game, so a restart ends it
+ * and this answers `held: false` — exactly the case core cannot otherwise
+ * see.
+ */
+ async inForce({ target } = {}) {
+ const r = await resolve(target)
+ if (!r.ok) return r
+ const result = await client.leaseList(r.server, { key: r.key, target: r.target })
+ if (!result.ok) return { ok: false, error: transportError(r.server, result, 'lease catalogue') }
+ const holds = (result.data && result.data.holds) || []
+ const held = holds.some((h) => h && h.key === r.key && String(h.target || '') === String(r.target || ''))
+ return { ok: true, held }
+ },
+ }
+}
+
+/** A convar named in the target, of this family. */
+const convarIn = (family) => (rest) =>
+ rest ? { ok: true, key: rest.toLowerCase() } : { ok: false, error: `name the ${family} value after the server, as server/convar` }
+
+const LEASES = [
+ lease({
+ id: 'rust.decay.scale',
+ label: 'Decay rate',
+ description:
+ 'How fast unprotected buildings decay. 1 is normal, 0 switches decay off, 2 doubles it. Read on every decay tick, so it takes effect at the next one.',
+ type: 'float',
+ min: 0,
+ max: 10,
+ family: 'decay',
+ targetLabel: 'Which server',
+ source: 'rust.options.servers',
+ example: 'main',
+ wire: (rest) => (rest ? { ok: false, error: 'the decay rate takes only a server as its target' } : { ok: true, key: 'decay.scale' }),
+ }),
+ lease({
+ id: 'rust.population',
+ label: 'Population',
+ description:
+ 'How many of one animal or vehicle the game keeps topped up, per square kilometre. Applied on the next spawn tick, so the world fills toward the new number rather than jumping to it.',
+ type: 'float',
+ min: 0,
+ max: 50,
+ family: 'population',
+ targetLabel: 'Which server and population',
+ source: 'rust.options.populations',
+ example: 'main/bear.population',
+ wire: convarIn('population'),
+ }),
+ lease({
+ id: 'rust.spawn.scalar',
+ label: 'Spawn scalar',
+ description:
+ "The population system's minimum spawn rate or density — what it runs at on an empty or quiet server, scaling up toward the maximum as players arrive.",
+ type: 'float',
+ min: 0,
+ max: 10,
+ family: 'spawn',
+ targetLabel: 'Which server and scalar',
+ source: 'rust.options.spawnscalars',
+ example: 'main/spawn.min_rate',
+ wire: convarIn('spawn'),
+ }),
+ lease({
+ id: 'rust.group.permission',
+ label: 'Group permission',
+ description:
+ "Whether a permission group carries a permission — \"group default holds kits.vip until Monday\" makes everybody VIP for the weekend. Given back at the end whether or not the site is still up; the game holds the deadline.",
+ type: 'bool',
+ family: null,
+ targetLabel: 'Which server, group and permission',
+ source: 'rust.options.grouppermissions',
+ example: 'main/default/kits.vip',
+ wire: (rest) => {
+ const slash = rest.lastIndexOf('/')
+ if (slash <= 0 || slash >= rest.length - 1) {
+ return { ok: false, error: 'a group permission is named as server/group/permission' }
+ }
+ return {
+ ok: true,
+ key: GROUP_PERMISSION_KEY,
+ target: `${rest.slice(0, slash).trim().toLowerCase()}/${rest.slice(slash + 1).trim().toLowerCase()}`,
+ }
+ },
+ }),
+]
+
+// ── Option sources (D78: only what this phase's leases read) ─────────────────
+//
+// Every one resolves live, and a server that does not answer contributes
+// nothing rather than failing the whole answer — one server being down must
+// never blank the form for the other five (§9). A source that returns `[]`
+// degrades its field to free text on core's side, which is the right failure:
+// the operator very often already knows the value.
+
+/** Bound one source's answer, and say so in the log when there was more. */
+function bounded(rows, sourceId) {
+ if (rows.length <= MAX_OPTIONS) return rows
+ log.warn('option source truncated', { source: sourceId, available: rows.length, served: MAX_OPTIONS })
+ return rows.slice(0, MAX_OPTIONS)
+}
+
+/** Every enabled server's own answer, in parallel, skipping the ones that fail. */
+async function perServer(ask) {
+ const list = await servers.listForPolling()
+ const settled = await Promise.allSettled(list.map(async (server) => ({ server, result: await ask(server) })))
+ return settled.filter((s) => s.status === 'fulfilled' && s.value.result && s.value.result.ok).map((s) => s.value)
+}
+
+/** The convars one family lends, per server, as whole targets. */
+async function familyOptions(family, sourceId) {
+ const answers = await perServer((server) => client.leaseList(server))
+ const rows = []
+ for (const { server, result } of answers) {
+ for (const r of (result.data && result.data.leases) || []) {
+ if (!r || r.family !== family || r.unreadable) continue
+ rows.push({ value: `${server.id}/${r.key}`, label: r.key, group: server.name || server.id })
+ }
+ }
+ return bounded(rows, sourceId)
+}
+
+const OPTION_SOURCES = [
+ {
+ id: 'rust.options.servers',
+ label: 'Rust servers',
+ description: 'Every enabled server on this site. A lease holds a value on one of them (D73).',
+ async resolve() {
+ const list = await servers.listForPolling()
+ return list.map((s) => ({ value: s.id, label: s.name || s.id }))
+ },
+ },
+ {
+ id: 'rust.options.populations',
+ label: 'Populations',
+ description: 'The animal and vehicle populations each server lends, read live from the game.',
+ async resolve() {
+ return familyOptions('population', 'rust.options.populations')
+ },
+ },
+ {
+ id: 'rust.options.spawnscalars',
+ label: 'Spawn scalars',
+ description: "The population system's rate and density scalars each server lends.",
+ async resolve() {
+ return familyOptions('spawn', 'rust.options.spawnscalars')
+ },
+ },
+ {
+ // Groups times registered permissions is a catalogue bigger than a dropdown
+ // holds on any server with a few plugins, so it narrows by the term.
+ id: 'rust.options.grouppermissions',
+ label: 'Group permissions',
+ description: 'A permission group and a permission some loaded plugin registered, on each server.',
+ searchable: true,
+ async resolve({ q } = {}) {
+ const term = String(q || '').trim().toLowerCase()
+ const answers = await perServer((server) => client.permCatalogue(server))
+ const rows = []
+ for (const { server, result } of answers) {
+ const data = result.data || {}
+ const perms = (data.permissions || []).map((p) => String(p).toLowerCase())
+ for (const g of data.groups || []) {
+ const group = g && g.name ? String(g.name).toLowerCase() : null
+ if (!group) continue
+ for (const perm of perms) {
+ const value = `${server.id}/${group}/${perm}`
+ if (term && !value.includes(term)) continue
+ rows.push({ value, label: `${group} · ${perm}`, group: server.name || server.id })
+ }
+ }
+ }
+ return bounded(rows, 'rust.options.grouppermissions')
+ },
+ },
+]
+
+module.exports = {
+ MAX_LEASE_MS,
+ LEASES,
+ OPTION_SOURCES,
+ splitTarget,
+}
diff --git a/server/index.js b/server/index.js
index 42ee166..2c0868b 100644
--- a/server/index.js
+++ b/server/index.js
@@ -56,6 +56,7 @@ module.exports = function register(ctx, api) {
const { STREAMS } = require('./engagement/streams')
const { AUDIENCES } = require('./engagement/audiences')
const seeds = require('./engagement/seeds')
+ const eventLeases = require('./eventLeases')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -146,8 +147,20 @@ module.exports = function register(ctx, api) {
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
- // Everything else this module will register — the four event catalogues, the
- // announce leg and the slash commands — is deliberately absent. Each arrives
+ // The leases (PLAN.md §27, protocol 8): what an event may BORROW on a server
+ // and must give back. Core's `core.lease` is the verb; these are the values it
+ // may name and the four callables each ships. Every lease is targeted and the
+ // target names the server (D73), which is how one value on one server gets
+ // exactly one holder without core learning what a server is.
+ //
+ // The option sources are the three targets' own (D78). **No budgets** (D79): a
+ // lease spends none, and a dimension with nothing to spend it is a dial on the
+ // operator's cap screen that does nothing. They arrive with the actions.
+ api.registerEventLeases(eventLeases.LEASES)
+ api.registerEventOptionSources(eventLeases.OPTION_SOURCES)
+
+ // Everything else this module will register — the event actions and budgets,
+ // the announce leg 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
@@ -161,5 +174,7 @@ module.exports = function register(ctx, api) {
triggers: TRIGGERS.length,
streams: STREAMS.length,
audiences: AUDIENCES.length,
+ leases: eventLeases.LEASES.length,
+ optionSources: eventLeases.OPTION_SOURCES.length,
})
}
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index 8fffaad..56e763f 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -52,13 +52,15 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
- * **7 — the raid frame.** Protocol 2 was the read path, 3 the first
+ * **8 — the leases.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`), 4 the first that writes to
* the game's permission store, 5 the first that writes to the game HOST'S
* FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
* built from, and no route at all; **7** widens `entity.destroyed` to doors,
* walls and the cupboard and names who is authorised there, which is what the
- * raid alert is sent to (PLAN.md §25). The bump lands here in the same change as the emitters,
+ * raid alert is sent to (PLAN.md §25); **8** adds the leases — `GET /lease`,
+ * `POST /lease` and `POST /lease/release` — which is what lets an event borrow
+ * a value on a server and give it back (PLAN.md §27). 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
@@ -68,7 +70,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
-const PROTOCOL_VERSION = 7
+const PROTOCOL_VERSION = 8
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -97,8 +99,10 @@ function joinUrl(baseUrl, path) {
* @param {object} [options]
* @param {string} [options.method]
* @param {object} [options.body]
+ * @param {number} [options.timeoutMs] shorter than `TIMEOUT_MS` only where a
+ * caller has a tighter budget of its own to fit inside — see `LEASE_TIMEOUT_MS`
*/
-async function request(server, path, { method = 'GET', body = null } = {}) {
+async function request(server, path, { method = 'GET', body = null, timeoutMs = TIMEOUT_MS } = {}) {
if (!server || !server.baseUrl) return reply(false, 'not-configured')
// A sidecar with auth off does not exist — it generates and persists a token on
@@ -108,7 +112,7 @@ async function request(server, path, { method = 'GET', body = null } = {}) {
if (!server.token) return reply(false, 'no-token')
const controller = new AbortController()
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(joinUrl(server.baseUrl, path), {
@@ -276,8 +280,61 @@ const configFile = (server, path) =>
*/
const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body })
+/**
+ * How long ONE lease call waits — shorter than every other call here, and for
+ * the same rule `TIMEOUT_MS` is written for, applied to a caller with a tighter
+ * budget.
+ *
+ * A lease is taken by core's `core.lease`, which declares no `budgetMs` and so
+ * runs under the dispatcher's default of 10 seconds — and inside that it makes
+ * TWO calls into this module, `read()` for the baseline and then `apply()`. At
+ * `TIMEOUT_MS` each, one slow read would let the dispatcher give up and call the
+ * attempt a retry while the module is still waiting, which is the ordering the
+ * header of this file exists to forbid. Two of these fit inside core's budget
+ * with a second to spare, and `leases.test.js` asserts the arithmetic rather than
+ * trusting it.
+ *
+ * It is below the sidecar's own ten-second reply timeout, so this end can give
+ * up on a call the game is still going to answer. For `read` that costs nothing.
+ * For `apply` it would leave a value held that core believes it never took — so
+ * `eventLeases.js` follows a timed-out apply with a release (§27.3).
+ */
+const LEASE_TIMEOUT_MS = 4500
+
+/** The dispatcher's default action budget, which is what `core.lease` runs under. Mirrored, not imported: core does not export it. */
+const CORE_LEASE_BUDGET_MS = 10000
+
+/**
+ * What one server lends, what it holds now, and every hold in force
+ * (protocol 8). Narrowed to one key and target when given.
+ */
+const leaseList = (server, { key, target } = {}) => {
+ const q = []
+ if (key) q.push(`key=${encodeURIComponent(key)}`)
+ if (target) q.push(`target=${encodeURIComponent(target)}`)
+ return request(server, `/lease${q.length ? `?${q.join('&')}` : ''}`, { timeoutMs: LEASE_TIMEOUT_MS })
+}
+
+/**
+ * Borrow a value (protocol 8). Like every write on this bridge, a refusal
+ * comes back `{ ok: true }` with `data.kind` of `lease.error`; the transport
+ * keeps its own codes.
+ */
+const leaseApply = (server, body) =>
+ request(server, '/lease', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
+
+/**
+ * Give a value back — compare-and-set at the far end. `data.kind` is
+ * `lease.ok`, `lease.drifted` (a 200: the plugin compared and declined to
+ * overwrite somebody's change) or `lease.error`.
+ */
+const leaseRelease = (server, body) =>
+ request(server, '/lease/release', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
+
module.exports = {
TIMEOUT_MS,
+ LEASE_TIMEOUT_MS,
+ CORE_LEASE_BUDGET_MS,
PROTOCOL_VERSION,
request,
health,
@@ -292,5 +349,8 @@ module.exports = {
configFiles,
configFile,
configWrite,
+ leaseList,
+ leaseApply,
+ leaseRelease,
joinUrl,
}
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 27e1ff0..0c2b576 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -139,15 +139,38 @@ test('nothing is registered that has nothing behind it yet', () => {
// that has something real to put in it, and this assertion is what that phase
// deletes. Phase 9 deleted the Team provider's line; phase 10 the four
// engagement lines, and the announce leg and post hook it deliberately did
- // NOT register (D62) moved into the assertions below.
+ // NOT register (D62) moved into the assertions below. Phase 12 deleted the
+ // leases and option sources, and kept budgets here on purpose (D79): a lease
+ // spends none, and a dimension nothing spends is a dial that does nothing.
assert.deepStrictEqual(api.record.legs, [])
assert.strictEqual(api.record.hooks.post, undefined)
assert.strictEqual(api.record.eventBudgets, null)
- assert.strictEqual(api.record.eventOptionSources, null)
- assert.strictEqual(api.record.eventLeases, null)
assert.strictEqual(api.record.eventActions, null)
})
+test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
+ const { api } = register()
+
+ const leases = api.record.eventLeases
+ const sources = api.record.eventOptionSources
+ assert.deepStrictEqual(
+ leases.map((l) => l.id).sort(),
+ ['rust.decay.scale', 'rust.group.permission', 'rust.population', 'rust.spawn.scalar'],
+ )
+
+ // D78: exactly the sources the leases' targets name — none without a reader.
+ const read = new Set(leases.map((l) => l.target.source))
+ assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
+
+ for (const l of leases) {
+ // D73: every lease is targeted, because the target is what names the server.
+ assert.ok(l.target && l.target.label && l.target.example, `${l.id} has no target`)
+ for (const fn of ['read', 'apply', 'restore', 'inForce']) {
+ assert.strictEqual(typeof l[fn], 'function', `${l.id} has no ${fn}()`)
+ }
+ }
+})
+
test('the engagement set is registered as one decision (phase 10, R7)', () => {
const { api } = register()
diff --git a/server/test/leases.test.js b/server/test/leases.test.js
new file mode 100644
index 0000000..f7e0f83
--- /dev/null
+++ b/server/test/leases.test.js
@@ -0,0 +1,329 @@
+// ── The leases (PLAN.md §27, protocol 8) ──────────────────────────────────
+//
+// Core owns the lease: it reads the baseline, reserves the target, applies the
+// value and restores it. What this module owns is four callables per lease, and
+// every test here is one of the ways those can be subtly wrong while looking
+// right:
+//
+// the target must name the server, or two servers share one holder (D73)
+// a duration crosses the wire, not a deadline, or two clocks disagree
+// drift is an ANSWER, not a failure of the call
+// "still held" is read from the holds, never inferred from a changed value
+// a key already held reads as its BASELINE, or teardown restores the event
+// an apply this end gave up on is followed by a release
+// two lease calls fit inside core.lease's budget, asserted not trusted
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+require('../core')._reset()
+require('../core').init(fakeCtx())
+
+const client = require('../sidecarClient')
+const serversDb = require('../model/servers/servers.db')
+const servers = require('../model/servers/servers.model')
+const { LEASES, OPTION_SOURCES, MAX_LEASE_MS, splitTarget } = require('../eventLeases')
+
+const byId = (id) => LEASES.find((l) => l.id === id)
+const source = (id) => OPTION_SOURCES.find((s) => s.id === id)
+
+const ROWS = {
+ main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
+ off: { id: 'off', name: 'Off', sidecarBaseUrl: 'http://off:1', sidecarTokenEnc: null, enabled: 0 },
+}
+
+/** Replace the module's collaborators for one test, and put them back after. */
+function stub(t, { list, apply, release, catalogue, polling } = {}) {
+ const calls = { list: [], apply: [], release: [], catalogue: [] }
+ const saved = {
+ getServer: serversDb.getServer,
+ listForPolling: servers.listForPolling,
+ leaseList: client.leaseList,
+ leaseApply: client.leaseApply,
+ leaseRelease: client.leaseRelease,
+ permCatalogue: client.permCatalogue,
+ }
+
+ serversDb.getServer = async (id) => ROWS[id] || null
+ servers.listForPolling = async () =>
+ (polling || ['main']).map((id) => ({ id, name: ROWS[id] ? ROWS[id].name : id, baseUrl: `http://${id}:1`, token: 't' }))
+ client.leaseList = async (server, q) => {
+ calls.list.push({ server: server.id, ...q })
+ return list ? list(server, q) : { ok: false, status: 'http-503' }
+ }
+ client.leaseApply = async (server, body) => {
+ calls.apply.push({ server: server.id, body })
+ return apply ? apply(server, body) : { ok: true, data: { kind: 'lease.ok' } }
+ }
+ client.leaseRelease = async (server, body) => {
+ calls.release.push({ server: server.id, body })
+ return release ? release(server, body) : { ok: true, data: { kind: 'lease.ok' } }
+ }
+ client.permCatalogue = async (server) => {
+ calls.catalogue.push(server.id)
+ return catalogue ? catalogue(server) : { ok: false, status: 'http-503' }
+ }
+
+ t.after(() => {
+ serversDb.getServer = saved.getServer
+ servers.listForPolling = saved.listForPolling
+ client.leaseList = saved.leaseList
+ client.leaseApply = saved.leaseApply
+ client.leaseRelease = saved.leaseRelease
+ client.permCatalogue = saved.permCatalogue
+ })
+
+ return calls
+}
+
+test('two lease calls fit inside the budget core.lease runs under', () => {
+ // `core.lease` declares no budgetMs, so the dispatcher's 10s default applies,
+ // and it spends it on read() THEN apply(). At the client's ordinary 12s either
+ // one alone would outlast the step, and the dispatcher would call a retry
+ // while this module was still waiting — the ordering sidecarClient's header
+ // exists to forbid.
+ assert.ok(2 * client.LEASE_TIMEOUT_MS < client.CORE_LEASE_BUDGET_MS)
+ assert.ok(client.LEASE_TIMEOUT_MS < client.TIMEOUT_MS)
+})
+
+test('every lease may be held for seven days and no longer (D77)', () => {
+ assert.strictEqual(MAX_LEASE_MS, 7 * 24 * 60 * 60 * 1000)
+ for (const l of LEASES) assert.strictEqual(l.maxDurationMs, MAX_LEASE_MS, l.id)
+})
+
+test('a target splits at the FIRST slash, so a group name may contain one', () => {
+ assert.deepStrictEqual(splitTarget('main'), { serverId: 'main', rest: '' })
+ assert.deepStrictEqual(splitTarget('main/bear.population'), { serverId: 'main', rest: 'bear.population' })
+ assert.deepStrictEqual(splitTarget('main/a/b/kits.vip'), { serverId: 'main', rest: 'a/b/kits.vip' })
+})
+
+test('a target naming no server, or a switched-off one, is refused for good', async (t) => {
+ stub(t)
+ const decay = byId('rust.decay.scale')
+
+ for (const target of ['', 'nowhere', 'off']) {
+ const answer = await decay.read({ target })
+ assert.strictEqual(answer.ok, false, target)
+ assert.strictEqual(answer.retry, false, `${target}: the second attempt carries the same params`)
+ }
+})
+
+test('the decay rate takes only a server, and a population needs one named', async (t) => {
+ stub(t)
+ const decay = await byId('rust.decay.scale').read({ target: 'main/bear.population' })
+ assert.strictEqual(decay.ok, false)
+ assert.strictEqual(decay.retry, false)
+
+ const pop = await byId('rust.population').read({ target: 'main' })
+ assert.strictEqual(pop.ok, false)
+ assert.strictEqual(pop.retry, false)
+})
+
+test('read asks the plugin about one key on the named server, and answers the current value as text', async (t) => {
+ const calls = stub(t, {
+ list: () => ({ ok: true, data: { leases: [{ key: 'bear.population', family: 'population', current: '2', held: false }], holds: [] } }),
+ })
+
+ const answer = await byId('rust.population').read({ target: 'main/Bear.Population' })
+ assert.deepStrictEqual(answer, { ok: true, value: '2' })
+ assert.deepStrictEqual(calls.list, [{ server: 'main', key: 'bear.population', target: undefined }])
+})
+
+test('a key from another family is refused even when the plugin lends it', async (t) => {
+ stub(t, {
+ list: () => ({ ok: true, data: { leases: [{ key: 'spawn.max_rate', family: 'spawn', current: '1' }] } }),
+ })
+ const answer = await byId('rust.population').read({ target: 'main/spawn.max_rate' })
+ assert.strictEqual(answer.ok, false)
+ assert.strictEqual(answer.retry, false)
+})
+
+test('a key the plugin already holds reads as its BASELINE, not the value the lease put there', async (t) => {
+ // Core's reservation stops a second run reaching read(). A hold core does not
+ // know about is this run's first attempt with its answer lost, and the value
+ // to give back at the end is what was there before anybody borrowed it.
+ stub(t, {
+ list: () => ({
+ ok: true,
+ data: { leases: [{ key: 'decay.scale', family: 'decay', current: '0', held: true, baseline: '1', applied: '0' }] },
+ }),
+ })
+ const answer = await byId('rust.decay.scale').read({ target: 'main' })
+ assert.deepStrictEqual(answer, { ok: true, value: '1' })
+})
+
+test('apply sends a DURATION, the family, and the value as text', async (t) => {
+ const calls = stub(t)
+ const until = new Date(Date.now() + 60 * 60 * 1000)
+
+ const answer = await byId('rust.population').apply(4, until, { target: 'main/bear.population' })
+ assert.deepStrictEqual(answer, { ok: true })
+
+ const { body } = calls.apply[0]
+ assert.strictEqual(body.key, 'bear.population')
+ assert.strictEqual(body.family, 'population')
+ assert.strictEqual(body.value, '4')
+ assert.strictEqual(body.untilMs, until.getTime())
+ // holdMs is what the plugin arms its timer with; it must be the remaining
+ // duration, measured here, and never the absolute time.
+ assert.ok(body.holdMs > 59 * 60 * 1000 && body.holdMs <= 60 * 60 * 1000, String(body.holdMs))
+})
+
+test('a group permission crosses as a key and a lowered group/permission target', async (t) => {
+ const calls = stub(t)
+ await byId('rust.group.permission').apply(true, new Date(Date.now() + 60000), { target: 'main/Default/Kits.VIP' })
+
+ const { body } = calls.apply[0]
+ assert.strictEqual(body.key, 'group.permission')
+ assert.strictEqual(body.target, 'default/kits.vip')
+ assert.strictEqual(body.value, 'true')
+ assert.strictEqual(body.family, undefined)
+})
+
+test('events switched off on the server is a refusal with the switch named, for good (D76)', async (t) => {
+ stub(t, {
+ apply: () => ({
+ ok: true,
+ data: { kind: 'lease.error', reason: 'events-disabled', message: 'events are switched off on this server — set EventsEnabled' },
+ }),
+ })
+ const answer = await byId('rust.decay.scale').apply(0, new Date(Date.now() + 60000), { target: 'main' })
+ assert.strictEqual(answer.ok, false)
+ assert.strictEqual(answer.retry, false)
+ assert.match(answer.error, /EventsEnabled/)
+})
+
+test('an apply this end gave up on is followed by a release of the same value', async (t) => {
+ const calls = stub(t, { apply: () => ({ ok: false, status: 'timeout' }) })
+
+ const answer = await byId('rust.decay.scale').apply(0, new Date(Date.now() + 60000), { target: 'main' })
+ assert.strictEqual(answer.ok, false)
+ assert.match(answer.error, /Main/, 'every notice says which server')
+
+ await new Promise((resolve) => setImmediate(resolve))
+ assert.strictEqual(calls.release.length, 1)
+ assert.deepStrictEqual(calls.release[0].body, { key: 'decay.scale', target: undefined, expected: '0' })
+})
+
+test('an apply that failed for any other reason sends no release', async (t) => {
+ const calls = stub(t, { apply: () => ({ ok: false, status: 'http-503' }) })
+ await byId('rust.decay.scale').apply(0, new Date(Date.now() + 60000), { target: 'main' })
+ await new Promise((resolve) => setImmediate(resolve))
+ assert.strictEqual(calls.release.length, 0)
+})
+
+test('an apply whose deadline has already passed is refused before anything is sent', async (t) => {
+ const calls = stub(t)
+ const answer = await byId('rust.decay.scale').apply(0, new Date(Date.now() - 1000), { target: 'main' })
+ assert.strictEqual(answer.ok, false)
+ assert.strictEqual(calls.apply.length, 0)
+})
+
+test('drift is an answer with the current value beside it, not a failed call', async (t) => {
+ const calls = stub(t, { release: () => ({ ok: true, data: { kind: 'lease.drifted', current: '3' } }) })
+
+ const answer = await byId('rust.decay.scale').restore(1, { expected: 0, target: 'main' })
+ assert.deepStrictEqual(answer, { ok: false, drifted: true, current: '3' })
+ assert.deepStrictEqual(calls.release[0].body, { key: 'decay.scale', expected: '0', baseline: '1' })
+})
+
+test('a release the plugin accepted — including a group that is gone — is a success', async (t) => {
+ stub(t, { release: () => ({ ok: true, data: { kind: 'lease.ok', targetGone: true } }) })
+ const answer = await byId('rust.group.permission').restore(false, { expected: true, target: 'main/vip/kits.vip' })
+ assert.deepStrictEqual(answer, { ok: true })
+})
+
+test('a release that could not be made is a failure core will ask again about', async (t) => {
+ stub(t, { release: () => ({ ok: false, status: 'http-503' }) })
+ const answer = await byId('rust.decay.scale').restore(1, { expected: 0, target: 'main' })
+ assert.strictEqual(answer.ok, false)
+ assert.strictEqual(answer.drifted, undefined)
+ assert.strictEqual(answer.retry, undefined)
+})
+
+test('"still held" is read from the holds, never from a value that changed', async (t) => {
+ // The current value differs from anything a lease applied — somebody moved it.
+ // That is DRIFT, for restore() to report; inForce must still say the hold is
+ // there, or core orphans the row before restore ever gets to say so.
+ stub(t, {
+ list: () => ({
+ ok: true,
+ data: {
+ leases: [{ key: 'decay.scale', family: 'decay', current: '7', held: true }],
+ holds: [{ key: 'decay.scale', applied: '0', baseline: '1' }],
+ },
+ }),
+ })
+ assert.deepStrictEqual(await byId('rust.decay.scale').inForce({ target: 'main' }), { ok: true, held: true })
+})
+
+test('after a restart the plugin holds nothing, and inForce says so', async (t) => {
+ stub(t, { list: () => ({ ok: true, data: { leases: [{ key: 'decay.scale', family: 'decay', current: '1' }], holds: [] } }) })
+ assert.deepStrictEqual(await byId('rust.decay.scale').inForce({ target: 'main' }), { ok: true, held: false })
+})
+
+test('inForce that cannot reach the game is not an answer', async (t) => {
+ stub(t)
+ const answer = await byId('rust.decay.scale').inForce({ target: 'main' })
+ assert.strictEqual(answer.ok, false)
+})
+
+test('a group permission hold is matched on its own target', async (t) => {
+ stub(t, {
+ list: () => ({ ok: true, data: { holds: [{ key: 'group.permission', target: 'default/kits.vip' }] } }),
+ })
+ const lease = byId('rust.group.permission')
+ assert.deepStrictEqual(await lease.inForce({ target: 'main/default/kits.vip' }), { ok: true, held: true })
+ assert.deepStrictEqual(await lease.inForce({ target: 'main/default/kits.gold' }), { ok: true, held: false })
+})
+
+test('the servers source is every enabled server, with no game call', async (t) => {
+ const calls = stub(t, { polling: ['main', 'creative'] })
+ const rows = await source('rust.options.servers').resolve()
+ assert.deepStrictEqual(rows.map((r) => r.value), ['main', 'creative'])
+ assert.strictEqual(calls.list.length, 0)
+})
+
+test('a family source lists whole targets, and one silent server blanks nothing', async (t) => {
+ stub(t, {
+ polling: ['main', 'creative'],
+ list: (server) =>
+ server.id === 'creative'
+ ? { ok: false, status: 'timeout' }
+ : {
+ ok: true,
+ data: {
+ leases: [
+ { key: 'decay.scale', family: 'decay', current: '1' },
+ { key: 'bear.population', family: 'population', current: '2' },
+ { key: 'zombie.population', family: 'population', unreadable: 'this server has no convar' },
+ ],
+ },
+ },
+ })
+
+ const rows = await source('rust.options.populations').resolve()
+ assert.deepStrictEqual(rows, [{ value: 'main/bear.population', label: 'bear.population', group: 'Main' }])
+})
+
+test('the group-permission source is searchable and narrows by the term', async (t) => {
+ stub(t, {
+ catalogue: () => ({
+ ok: true,
+ data: { permissions: ['kits.vip', 'Kits.Gold', 'zonemanager.admin'], groups: [{ name: 'default' }, { name: 'VIP' }] },
+ }),
+ })
+ const src = source('rust.options.grouppermissions')
+ assert.strictEqual(src.searchable, true)
+
+ const all = await src.resolve({ q: '' })
+ assert.strictEqual(all.length, 6)
+
+ const narrowed = await src.resolve({ q: 'kits' })
+ assert.deepStrictEqual(
+ narrowed.map((r) => r.value).sort(),
+ ['main/default/kits.gold', 'main/default/kits.vip', 'main/vip/kits.gold', 'main/vip/kits.vip'],
+ )
+})
--
2.49.1
From a3bcec9cde95cbf0957782607960116f979eafd8 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Thu, 24 Sep 2026 01:26:50 -0500
Subject: [PATCH 15/51] feat(rust): the world verbs, their budgets and the
reconcile watch (phase 13a, protocol 9)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- registerEventActions: rust.zone.open and rust.prefab.place, both
reversible 'ledger' with revert() and reconcile(), budgetMs 15000 above the
client's 12 s. A location is a monument (kind + instance, carrying its
server) or raw coordinates, exactly one (D87, D93); bounds mirrored from the
plugin so a bad step is refused on the form (D95); zone minutes required and
held by the game (D96).
- registerEventBudgets: rust.prefabs, rust.npcs and rust.zone.minutes, each
beside the verb that spends it (D79, D89).
- Option sources rust.options.monuments (live, searchable) and
rust.options.prefabs (mirrored, answers with every server off), registered in
the one batch core accepts alongside the lease sources.
- Refs are :, since revert and reconcile get no params. The undo
sends no idempotency key; a lost answer is reverted by key on every server.
reconcile asks the plugin, and a server that cannot be asked keeps its rows.
- The refresh's bootId/wipeId watch calls ctx.events.reconcile() on a restart
or a wipe, never on a first sighting or a reconnect (§11.1).
- The permission mirror keeps the plugin's new notLanded grants out of what it
records as pushed, and the admin page says so (D85).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
ci/bundle.json | 1 +
client/src/routes/admin/Permissions.jsx | 10 +
server/boot.js | 6 +
server/eventLeases.js | 6 +
server/eventWorld.js | 616 ++++++++++++++++++++++++
server/index.js | 24 +-
server/permSync.js | 10 +-
server/sidecarClient.js | 40 +-
server/test/entry.test.js | 39 +-
server/test/permissions.test.js | 37 ++
server/test/world.test.js | 325 +++++++++++++
11 files changed, 1101 insertions(+), 13 deletions(-)
create mode 100644 server/eventWorld.js
create mode 100644 server/test/world.test.js
diff --git a/ci/bundle.json b/ci/bundle.json
index 5e8b335..c5d0c57 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -34,6 +34,7 @@
"db",
"engagement",
"eventLeases.js",
+ "eventWorld.js",
"index.js",
"ingest.js",
"model",
diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx
index 27661f0..d57d130 100644
--- a/client/src/routes/admin/Permissions.jsx
+++ b/client/src/routes/admin/Permissions.jsx
@@ -97,6 +97,7 @@ function ServerState({ row, onSync, busy }) {
const report = row.report || {}
const unresolved = report.unresolved || []
const pending = report.pending || []
+ const notLanded = report.notLanded || []
return (
@@ -141,6 +142,15 @@ function ServerState({ row, onSync, busy }) {
in a group yet.
)}
+
+ {notLanded.length > 0 && (
+
+ {notLanded.length} {notLanded.length === 1 ? 'grant was' : 'grants were'} sent and not found
+ in the game's permission store afterwards ({notLanded.slice(0, 5).join(', ')}
+ {notLanded.length > 5 ? ', …' : ''}). They are not counted as pushed, and the next sync
+ tries again.
+
+ )}
)
}
diff --git a/server/boot.js b/server/boot.js
index e711dd5..af4770c 100644
--- a/server/boot.js
+++ b/server/boot.js
@@ -45,6 +45,7 @@ const core = require('./core')
const db = require('./model/servers/servers.db')
const engagement = require('./engagement/emit')
const eventsDb = require('./model/events/events.db')
+const eventWorld = require('./eventWorld')
const ingest = require('./ingest')
const permSync = require('./permSync')
const servers = require('./model/servers/servers.model')
@@ -180,6 +181,11 @@ async function refreshOne(server) {
// After the write, so a transition announced is one a page already shows.
engagement.serverObserved(server, connected)
+
+ // A restart or a wipe under a running event is the moment core must be told
+ // to ask what the world still holds (§11.1). Only a CONNECTED plugin's hello
+ // counts: a board the game left behind says nothing about now.
+ if (connected) eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId })
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`
// as a rejection that hides which one. Log with the id and carry on.
diff --git a/server/eventLeases.js b/server/eventLeases.js
index 7d18d5f..09189f9 100644
--- a/server/eventLeases.js
+++ b/server/eventLeases.js
@@ -428,7 +428,13 @@ const OPTION_SOURCES = [
module.exports = {
MAX_LEASE_MS,
+ MAX_OPTIONS,
LEASES,
OPTION_SOURCES,
splitTarget,
+ serverFor,
+ transportError,
+ pluginError,
+ perServer,
+ bounded,
}
diff --git a/server/eventWorld.js b/server/eventWorld.js
new file mode 100644
index 0000000..7f38226
--- /dev/null
+++ b/server/eventWorld.js
@@ -0,0 +1,616 @@
+// ── What an event MAKES on a Rust server (PLAN.md §28, protocol 9) ────────
+//
+// A lease borrows a value that was already there. These two verbs make
+// something that was not — a zone, and crates or NPCs placed in the world — and
+// give it back at teardown. Everything that decides what is allowed lives on the
+// plugin: the allowlist, the bounds, the monument vocabulary, the registry of
+// what each run owns. What is here is the contract's half: declarations core can
+// check an author's step against, and the three callables core calls.
+//
+// ── Four facts from the rig shape all of it (§28.1) ──────────────────────────
+//
+// * A restart is NOT proof a placed thing is gone. Crates are saved by the
+// game and come back with the same net id; NPCs are not. So `reconcile` asks
+// the plugin, which looks — `module-uo`'s `reconcileByBootId` trick would
+// orphan every crate on every restart.
+// * A wipe IS proof everything is gone, and the plugin drops its registry.
+// * The bridge has no at-most-once store, so its registry is keyed by core's
+// idempotency key: a retried step is answered with the first call's ids.
+// * Monument names repeat, so a monument is named by kind and instance (D93).
+//
+// ── The ref names the server ─────────────────────────────────────────────────
+//
+// Every resource is `:`. `revert` and `reconcile` are handed
+// resources and not the step's params, and a run may reach six servers; the ref
+// is the only place the server can travel with the thing.
+
+const core = require('./core')
+const client = require('./sidecarClient')
+const servers = require('./model/servers/servers.model')
+const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
+
+const log = core.logger('world')
+
+/**
+ * The budget every verb here declares. It must EXCEED the client's own timeout
+ * (`TIMEOUT_MS`, 12 s), which in turn exceeds the sidecar's ten-second reply
+ * timeout — otherwise core gives up first and a `retry: false` this module
+ * answered is unreachable (MODULE_API §2.4). `world.test.js` asserts the order.
+ */
+const BUDGET_MS = 15000
+
+// Mirrors of the plugin's bounds (D95, D96). The plugin's are authoritative and
+// an operator may set them lower, in which case its refusal is the one that
+// lands; these exist so a bad step is a refusal on the AUTHORING FORM and in a
+// dry run, rather than a step failing unattended at four in the morning.
+const MAX_CRATES = 25
+const MAX_NPCS = 20
+const MAX_SPREAD = 50
+const MAX_OFFSET = 150
+const ZONE_MIN_RADIUS = 5
+const ZONE_MAX_RADIUS = 150
+const ZONE_MAX_MINUTES = 7 * 24 * 60
+
+/** The ledger kind both verbs file under. */
+const OWNED_KIND = 'world'
+
+/**
+ * What the plugin will place, mirroring its allowlist (D88).
+ *
+ * **Two copies of a short list, deliberately** — `module-uo`'s `GRANTABLE`
+ * argument. This one prices a step (`cost()` is synchronous and cannot ask a
+ * game) and fills the dropdown with every server off; the plugin's is what is
+ * true when this one is wrong.
+ */
+const PLACEABLE = [
+ { key: 'crate.basic', kind: 'crate', label: 'Basic crate' },
+ { key: 'crate.normal', kind: 'crate', label: 'Military crate' },
+ { key: 'crate.normal2', kind: 'crate', label: 'Crate' },
+ { key: 'crate.elite', kind: 'crate', label: 'Elite crate' },
+ { key: 'crate.tools', kind: 'crate', label: 'Tool box' },
+ { key: 'crate.hackable', kind: 'crate', label: 'Locked crate (hackable)' },
+ { key: 'supply.drop', kind: 'crate', label: 'Supply drop' },
+ { key: 'barrel.loot', kind: 'crate', label: 'Loot barrel' },
+ { key: 'npc.scientist', kind: 'npc', label: 'Scientist' },
+ { key: 'npc.scientist.heavy', kind: 'npc', label: 'Heavy scientist' },
+ { key: 'npc.scientist.tethered', kind: 'npc', label: 'Scientist (stays put)' },
+ { key: 'npc.bandit.guard', kind: 'npc', label: 'Bandit guard' },
+]
+
+/** The plugin's refusals a second attempt would repeat. Anything else is left to core's default. */
+const PERMANENT = new Set([
+ 'events-disabled',
+ 'malformed',
+ 'unknown-prefab',
+ 'out-of-range',
+ 'no-monument',
+ 'off-map',
+ 'zonemanager-missing',
+])
+
+const BUDGETS = [
+ {
+ id: 'rust.prefabs',
+ label: 'Crates placed',
+ unit: 'crates',
+ description: 'Crates, barrels and supply drops an event puts in the world. Counted per server a run reaches.',
+ },
+ {
+ id: 'rust.npcs',
+ label: 'NPCs placed',
+ unit: 'NPCs',
+ description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
+ },
+ {
+ id: 'rust.zone.minutes',
+ label: 'Zone time',
+ unit: 'minutes',
+ description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
+ },
+]
+
+/** A number param, or undefined when left blank. */
+function num(raw) {
+ if (raw === undefined || raw === null || raw === '') return undefined
+ const value = Number(raw)
+ return Number.isFinite(value) ? value : NaN
+}
+
+/**
+ * Where a step puts its thing — a monument plus an offset, or raw coordinates,
+ * and exactly one of the two (D87) — and which server that is on.
+ *
+ * A monument value carries its server (`srv-a/harbor_1#2`, D93), so a monument
+ * step needs no `server`; one that gives both must agree. Raw coordinates name
+ * nothing, so they need `server`. Every refusal is `retry: false`: the second
+ * attempt has the same params.
+ */
+function location(params) {
+ const monument = String(params.monument || '').trim()
+ const x = num(params.x)
+ const z = num(params.z)
+ const y = num(params.y)
+ const byCoords = x !== undefined || z !== undefined
+ let serverId = String(params.server || '').trim()
+
+ if (Boolean(monument) === byCoords) {
+ return { ok: false, error: 'a location is a monument or x and z, and exactly one of them' }
+ }
+
+ if (monument) {
+ const slash = monument.indexOf('/')
+ if (slash <= 0 || slash === monument.length - 1) {
+ return { ok: false, error: `"${monument}" is not a monument — pick one from the list, as server/monument` }
+ }
+ const onServer = monument.slice(0, slash)
+ if (serverId && serverId !== onServer) {
+ return { ok: false, error: `that monument is on ${onServer}, not ${serverId}` }
+ }
+ serverId = onServer
+
+ const offsetX = num(params.offsetX) ?? 0
+ const offsetZ = num(params.offsetZ) ?? 0
+ if (Number.isNaN(offsetX) || Number.isNaN(offsetZ)) return { ok: false, error: 'an offset is a number of metres' }
+ if (Math.hypot(offsetX, offsetZ) > MAX_OFFSET) {
+ return { ok: false, error: `an offset from a monument is at most ${MAX_OFFSET} m` }
+ }
+
+ return { ok: true, serverId, wire: { monument: monument.slice(slash + 1), offsetX, offsetZ } }
+ }
+
+ if (x === undefined || z === undefined || Number.isNaN(x) || Number.isNaN(z)) {
+ return { ok: false, error: 'coordinates need both x and z, as numbers' }
+ }
+ if (Number.isNaN(y)) return { ok: false, error: 'y is a number of metres, or left blank for the ground' }
+ if (!serverId) return { ok: false, error: 'coordinates do not say which server — pick one' }
+
+ return { ok: true, serverId, wire: { x, z, ...(y === undefined ? {} : { y }) } }
+}
+
+/** `:` — see the header. */
+const refOf = (serverId, id) => `${serverId}:${id}`
+
+/** A ref split back into its server and id, at the FIRST colon (a server id has none). */
+function splitRef(ref) {
+ const text = String(ref || '')
+ const colon = text.indexOf(':')
+ return colon <= 0 ? { serverId: null, id: text } : { serverId: text.slice(0, colon), id: text.slice(colon + 1) }
+}
+
+/** Resources grouped by the server each one is on. */
+function byServer(resources) {
+ const groups = new Map()
+ for (const resource of resources || []) {
+ const serverId = (resource.payload && resource.payload.serverId) || splitRef(resource.ref).serverId
+ if (!groups.has(serverId)) groups.set(serverId, [])
+ groups.get(serverId).push(resource)
+ }
+ return groups
+}
+
+/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
+function transportFailure(server, result, what) {
+ const permanent = result.status === 'not-configured' || result.status === 'no-token'
+ return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
+}
+
+/**
+ * Send one world write and file what came back.
+ *
+ * One resource per id — per crate, per NPC, per zone — like `module-uo`'s one
+ * per serial, so a group half of which players looted reconciles per crate
+ * rather than all or nothing.
+ */
+async function place(server, send, body, what) {
+ const result = await send(server, body)
+ if (!result.ok) return transportFailure(server, result, what)
+
+ const data = result.data || {}
+ if (data.kind !== 'world.ok') {
+ return {
+ ok: false,
+ ...(PERMANENT.has(data.reason) ? { retry: false } : {}),
+ error: pluginError(data, `${server.name || server.id} refused the ${what}`),
+ }
+ }
+
+ const placed = Array.isArray(data.placed) ? data.placed : []
+ return {
+ ok: true,
+ resources: placed.map((row) => ({
+ kind: OWNED_KIND,
+ ref: refOf(server.id, row.id),
+ payload: {
+ serverId: server.id,
+ what: row.kind,
+ ...(row.prefab ? { prefab: row.prefab } : {}),
+ ...(row.name ? { name: row.name } : {}),
+ },
+ })),
+ ...(data.repeat ? { detail: { repeat: true, note: 'answered from the first attempt; nothing new was placed' } } : {}),
+ }
+}
+
+/**
+ * Give back what a step made.
+ *
+ * **No idempotency key goes with it.** `module-uo` shipped exactly that
+ * mistake: its despawn carried the key the spawn went out under, the shard
+ * recognised a repeat of the DO and answered with the spawn's reply, and every
+ * teardown was a no-op that reported success (MODULE_API §2.4). A repeated
+ * revert is safe here without one — the second finds everything `gone`.
+ *
+ * The one case the key IS for is the lost answer: core knows a dispatch went
+ * out under it and never learned what it made, so `resources` is empty. The
+ * step's server is not known then either — it was a param, and params do not
+ * reach `revert` — so every enabled server is asked to give back whatever this
+ * run placed under that key. A server that cannot be asked leaves the row
+ * visible rather than guessing.
+ */
+async function revert({ runId, resources, idempotencyKey }) {
+ const failed = []
+ const errors = []
+
+ if (!resources || resources.length === 0) {
+ if (!idempotencyKey) return { ok: true }
+ for (const server of await servers.listForPolling()) {
+ const result = await client.worldRevert(server, { runId: String(runId), key: idempotencyKey })
+ if (!result.ok) errors.push(transportError(server, result, 'revert'))
+ }
+ return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
+ }
+
+ for (const [serverId, group] of byServer(resources)) {
+ const found = await serverFor(serverId)
+ if (!found.ok) {
+ failed.push(...group.map((r) => r.ref))
+ errors.push(found.error)
+ continue
+ }
+
+ const result = await client.worldRevert(found.server, {
+ runId: String(runId),
+ ids: group.map((r) => splitRef(r.ref).id),
+ })
+
+ if (!result.ok) {
+ failed.push(...group.map((r) => r.ref))
+ errors.push(transportError(found.server, result, 'revert'))
+ continue
+ }
+
+ // `gone` is not reported: a crate a player looted is the point of having
+ // placed it. `refused` IS — the plugin found something there that this run
+ // did not make, and nothing will ever remove it through this path.
+ const refused = new Set(((result.data && result.data.refused) || []).map(String))
+ for (const r of group) if (refused.has(splitRef(r.ref).id)) failed.push(r.ref)
+ }
+
+ if (!failed.length) return { ok: true }
+ if (failed.length === resources.length && errors.length) return { ok: false, error: errors.join('; ') }
+ return { ok: true, failed }
+}
+
+/**
+ * Which of these does the world still hold?
+ *
+ * The plugin LOOKS for each one, by net id or zone id. A server that cannot be
+ * asked has said nothing, so its resources are all reported in force — "I do
+ * not know" is never "it is gone" (MODULE_API §1.1).
+ */
+async function reconcile({ runId, resources }) {
+ const inForce = []
+
+ for (const [serverId, group] of byServer(resources)) {
+ const found = await serverFor(serverId)
+ const result = found.ok ? await client.worldOwned(found.server, { runId: String(runId) }) : null
+
+ if (!result || !result.ok || !result.data || !Array.isArray(result.data.owned)) {
+ inForce.push(...group.map((r) => r.ref))
+ continue
+ }
+
+ const held = new Set(result.data.owned.map((row) => String(row.id)))
+ for (const r of group) if (held.has(splitRef(r.ref).id)) inForce.push(r.ref)
+ }
+
+ return { ok: true, inForce }
+}
+
+/** The location params both verbs share, so two declarations cannot drift apart. */
+const LOCATION_PARAMS = [
+ {
+ name: 'monument',
+ type: 'string',
+ required: false,
+ example: 'main/powerplant_1',
+ source: 'rust.options.monuments',
+ description: 'Where, by monument. Give this OR x and z. Names the server too.',
+ },
+ {
+ name: 'offsetX',
+ type: 'float',
+ required: false,
+ example: 20,
+ description: `Metres east of the monument's centre (negative is west). Up to ${MAX_OFFSET} m from it in all.`,
+ },
+ {
+ name: 'offsetZ',
+ type: 'float',
+ required: false,
+ example: -15,
+ description: "Metres north of the monument's centre (negative is south).",
+ },
+ {
+ name: 'server',
+ type: 'string',
+ required: false,
+ example: 'main',
+ source: 'rust.options.servers',
+ description: 'Which server, when the location is coordinates. A monument already says.',
+ },
+ { name: 'x', type: 'float', required: false, example: -604, description: 'World x, instead of a monument.' },
+ { name: 'z', type: 'float', required: false, example: -342, description: 'World z, instead of a monument.' },
+ {
+ name: 'y',
+ type: 'float',
+ required: false,
+ example: 30,
+ description: 'Height. Left blank, the ground at x and z.',
+ },
+]
+
+const WORLD_COMMON = {
+ // Something appears where there was nothing. §K puts the default-off line
+ // between `inspect` and `change`, so an operator switches these on
+ // deliberately — the right consent for an unattended change to a live world.
+ risk: 'change',
+ reversible: 'ledger',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ revert,
+ reconcile,
+}
+
+const ACTIONS = [
+ {
+ ...WORLD_COMMON,
+ id: 'rust.zone.open',
+ label: 'Open a zone',
+ description:
+ 'A ZoneManager zone at a monument or a point, for a set number of minutes. The game erases it when they run out, even if this site is down; teardown erases it sooner.',
+ cost: (p) => ({ 'rust.zone.minutes': Math.max(0, Math.round(Number(p.minutes) || 0)) }),
+ params: [
+ ...LOCATION_PARAMS,
+ {
+ name: 'radius',
+ type: 'float',
+ required: true,
+ example: 40,
+ description: `How far the zone reaches, ${ZONE_MIN_RADIUS} to ${ZONE_MAX_RADIUS} m.`,
+ },
+ {
+ name: 'minutes',
+ type: 'int',
+ required: true,
+ example: 120,
+ description: `How long it stands, up to ${ZONE_MAX_MINUTES} (seven days). Counted against zone time.`,
+ },
+ { name: 'name', type: 'string', required: false, example: 'Airfield brawl', description: 'What the zone is called.' },
+ ],
+
+ async perform({ runId, idempotencyKey, params, verify }) {
+ const where = location(params)
+ if (!where.ok) return { ok: false, retry: false, error: where.error }
+
+ const radius = Number(params.radius)
+ if (!Number.isFinite(radius) || radius < ZONE_MIN_RADIUS || radius > ZONE_MAX_RADIUS) {
+ return { ok: false, retry: false, error: `a zone's radius is ${ZONE_MIN_RADIUS} to ${ZONE_MAX_RADIUS} m, not "${params.radius}"` }
+ }
+
+ const minutes = Number(params.minutes)
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > ZONE_MAX_MINUTES) {
+ return { ok: false, retry: false, error: `a zone stands for 1 to ${ZONE_MAX_MINUTES} minutes, not "${params.minutes}"` }
+ }
+
+ const found = await serverFor(where.serverId)
+ if (!found.ok) return found
+
+ // The dry run stops here, and has checked everything it can without the
+ // game. It does not ask whether the monument exists: a step authored for
+ // next wipe's map would fail every dry run until the wipe.
+ if (verify) return { ok: true }
+
+ return place(
+ found.server,
+ client.worldZone,
+ {
+ runId: String(runId),
+ key: idempotencyKey,
+ ...where.wire,
+ radius,
+ holdMs: minutes * 60000,
+ ...(params.name ? { name: String(params.name).slice(0, 64) } : {}),
+ },
+ 'zone',
+ )
+ },
+ },
+ {
+ ...WORLD_COMMON,
+ id: 'rust.prefab.place',
+ label: 'Place crates or NPCs',
+ description:
+ 'Crates, a supply drop or NPCs at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.',
+ cost: (p) => {
+ const known = PLACEABLE.find((x) => x.key === String(p.prefab || '').trim())
+ const count = Math.max(0, Math.round(Number(p.count) || 0))
+ return { [known && known.kind === 'npc' ? 'rust.npcs' : 'rust.prefabs']: count }
+ },
+ params: [
+ {
+ name: 'prefab',
+ type: 'string',
+ required: true,
+ example: 'crate.elite',
+ source: 'rust.options.prefabs',
+ description: "What to place. The list is the server's own allowlist: crates and NPCs, never vehicles.",
+ },
+ {
+ name: 'count',
+ type: 'int',
+ required: true,
+ example: 3,
+ description: `How many — up to ${MAX_CRATES} crates or ${MAX_NPCS} NPCs at a time.`,
+ },
+ {
+ name: 'spread',
+ type: 'float',
+ required: false,
+ example: 10,
+ description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
+ },
+ ...LOCATION_PARAMS,
+ ],
+
+ async perform({ runId, idempotencyKey, params, verify }) {
+ const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
+ if (!known) return { ok: false, retry: false, error: `"${params.prefab}" is not something a Rust server places for events` }
+
+ const max = known.kind === 'npc' ? MAX_NPCS : MAX_CRATES
+ const count = Number(params.count)
+ if (!Number.isInteger(count) || count < 1 || count > max) {
+ return {
+ ok: false,
+ retry: false,
+ error: `place 1 to ${max} ${known.kind === 'npc' ? 'NPCs' : 'crates'} at a time, and "${params.count}" is not that`,
+ }
+ }
+
+ const spread = num(params.spread)
+ if (Number.isNaN(spread) || (spread !== undefined && (spread < 0 || spread > MAX_SPREAD))) {
+ return { ok: false, retry: false, error: `a scatter is 0 to ${MAX_SPREAD} m, not "${params.spread}"` }
+ }
+
+ const where = location(params)
+ if (!where.ok) return { ok: false, retry: false, error: where.error }
+
+ const found = await serverFor(where.serverId)
+ if (!found.ok) return found
+
+ if (verify) return { ok: true }
+
+ return place(
+ found.server,
+ client.worldPlace,
+ {
+ runId: String(runId),
+ key: idempotencyKey,
+ prefab: known.key,
+ count,
+ ...(spread === undefined ? {} : { spread }),
+ ...where.wire,
+ },
+ known.label.toLowerCase(),
+ )
+ },
+ },
+]
+
+const OPTION_SOURCES = [
+ {
+ // Every server's map, live. A procedural map changes at every wipe, so a
+ // cached list would offer monuments that are not there any more.
+ id: 'rust.options.monuments',
+ label: 'Monuments',
+ description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first (D93).",
+ searchable: true,
+ async resolve({ q } = {}) {
+ const term = String(q || '').trim().toLowerCase()
+ const answers = await perServer((server) => client.worldMonuments(server))
+ const rows = []
+ for (const { server, result } of answers) {
+ for (const m of (result.data && result.data.monuments) || []) {
+ if (!m || !m.value) continue
+ const label = `${m.label}${m.of > 1 ? ` #${m.instance}` : ''}${m.grid ? ` · ${m.grid}` : ''}`
+ const value = `${server.id}/${m.value}`
+ if (term && !value.toLowerCase().includes(term) && !label.toLowerCase().includes(term)) continue
+ rows.push({ value, label, group: server.name || server.id })
+ }
+ }
+ return bounded(rows, 'rust.options.monuments')
+ },
+ },
+ {
+ // From the mirror, so it answers with every server off (the field it fills
+ // must never be taken away by an outage, MODULE_API §2.4).
+ id: 'rust.options.prefabs',
+ label: 'Things to place',
+ description: 'Crates and NPCs a Rust server places for events.',
+ async resolve() {
+ return PLACEABLE.map((p) => ({ value: p.key, label: p.label, group: p.kind === 'npc' ? 'NPCs' : 'Crates' }))
+ },
+ },
+]
+
+// ── The watch (§11.1) ───────────────────────────────────────────────────────
+//
+// Core asks the module what the world still holds once, at its own boot, and
+// otherwise waits to be told. A game that restarted or wiped under a running
+// event is the moment to tell it: the boot id changes on a restart, the wipe
+// id on a wipe, and neither changes on a sidecar reconnect — which loses
+// nothing and must not provoke a sweep.
+
+const lastSeen = new Map()
+
+/**
+ * Note a server's identity as the refresh saw it, and ask core to reconcile when
+ * it moved. The first sighting after this module boots is a baseline, not a
+ * change: core's own boot reconcile already covered it.
+ */
+function observeServer(serverId, { bootId, wipeId } = {}) {
+ if (!serverId || (!bootId && !wipeId)) return false
+
+ const previous = lastSeen.get(serverId)
+ lastSeen.set(serverId, { bootId: bootId || null, wipeId: wipeId || null })
+ if (!previous) return false
+
+ const restarted = Boolean(bootId && previous.bootId && bootId !== previous.bootId)
+ const wiped = Boolean(wipeId && previous.wipeId && wipeId !== previous.wipeId)
+ if (!restarted && !wiped) return false
+
+ log.info('game changed under the events ledger; asking core to reconcile', {
+ server: serverId,
+ ...(restarted ? { restarted: { from: previous.bootId, to: bootId } } : {}),
+ ...(wiped ? { wiped: { from: previous.wipeId, to: wipeId } } : {}),
+ })
+
+ try {
+ Promise.resolve(core.reconcileEvents()).catch((err) => log.warn('reconcile failed', { error: err.message }))
+ } catch (err) {
+ log.warn('reconcile failed', { error: err.message })
+ }
+ return true
+}
+
+/** For tests. */
+function resetWatch() {
+ lastSeen.clear()
+}
+
+module.exports = {
+ BUDGET_MS,
+ MAX_CRATES,
+ MAX_NPCS,
+ ZONE_MAX_MINUTES,
+ PLACEABLE,
+ BUDGETS,
+ ACTIONS,
+ OPTION_SOURCES,
+ location,
+ splitRef,
+ revert,
+ reconcile,
+ observeServer,
+ resetWatch,
+}
diff --git a/server/index.js b/server/index.js
index 2c0868b..959459d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -57,6 +57,7 @@ module.exports = function register(ctx, api) {
const { AUDIENCES } = require('./engagement/audiences')
const seeds = require('./engagement/seeds')
const eventLeases = require('./eventLeases')
+ const eventWorld = require('./eventWorld')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -153,14 +154,21 @@ module.exports = function register(ctx, api) {
// target names the server (D73), which is how one value on one server gets
// exactly one holder without core learning what a server is.
//
- // The option sources are the three targets' own (D78). **No budgets** (D79): a
- // lease spends none, and a dimension with nothing to spend it is a dial on the
- // operator's cap screen that does nothing. They arrive with the actions.
+ // The option sources are the three targets' own (D78).
api.registerEventLeases(eventLeases.LEASES)
- api.registerEventOptionSources(eventLeases.OPTION_SOURCES)
- // Everything else this module will register — the event actions and budgets,
- // the announce leg and the slash commands — is deliberately absent. Each arrives
+ // The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
+ // back — a zone, crates, NPCs — and the budgets that price them, each declared
+ // beside the verb that spends it (D79, D89). A lease spends none of them.
+ api.registerEventBudgets(eventWorld.BUDGETS)
+ api.registerEventActions(eventWorld.ACTIONS)
+
+ // ONE call for every option source: core takes a batch once, as this module's
+ // complete statement, and refuses a second.
+ api.registerEventOptionSources([...eventLeases.OPTION_SOURCES, ...eventWorld.OPTION_SOURCES])
+
+ // Everything else this module will register — the rewards and the announce
+ // leg (13b), 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
@@ -175,6 +183,8 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
- optionSources: eventLeases.OPTION_SOURCES.length,
+ actions: eventWorld.ACTIONS.length,
+ budgets: eventWorld.BUDGETS.length,
+ optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length,
})
}
diff --git a/server/permSync.js b/server/permSync.js
index 00881e1..9f2bdb9 100644
--- a/server/permSync.js
+++ b/server/permSync.js
@@ -267,6 +267,11 @@ async function syncOne(server, { authored, sync, state, force }) {
async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || [])
+ // Grants the plugin made and then did not find in the store when it read it
+ // back (D85). Before protocol 9 there was no such read-back, and on Oxide every
+ // grant of another plugin's permission landed nowhere while this site recorded
+ // it as pushed (PLAN.md §27.6).
+ const notLanded = new Set((report.notLanded || []).map((entry) => String(entry).toLowerCase()))
// A grant naming a permission this server has not registered did NOT land —
// `GrantUserPermission` no-ops silently for an unregistered name, which is
@@ -276,7 +281,9 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
// 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 === 'grant' || row.kind === 'group-permission') {
+ return !unresolved.has(row.object) && !notLanded.has(`${row.subject}:${row.object}`.toLowerCase())
+ }
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
return true
})
@@ -325,6 +332,7 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
unresolved: (report.unresolved || []).length,
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
+ notLanded: (report.notLanded || []).length,
})
}
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index 56e763f..715d574 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -60,7 +60,10 @@ const TIMEOUT_MS = 12000
* walls and the cupboard and names who is authorised there, which is what the
* raid alert is sent to (PLAN.md §25); **8** adds the leases — `GET /lease`,
* `POST /lease` and `POST /lease/release` — which is what lets an event borrow
- * a value on a server and give it back (PLAN.md §27). The bump lands here in the same change as the emitters,
+ * a value on a server and give it back (PLAN.md §27); **9** adds the world
+ * verbs — `/world/monuments`, `/world/owned`, `/world/zone`, `/world/place` and
+ * `/world/revert` — what an event places in the world and gives back (PLAN.md
+ * §28). 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
@@ -70,7 +73,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
-const PROTOCOL_VERSION = 8
+const PROTOCOL_VERSION = 9
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -331,6 +334,34 @@ const leaseApply = (server, body) =>
const leaseRelease = (server, body) =>
request(server, '/lease/release', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
+/**
+ * This wipe's monuments, the plugin's placeable allowlist and its bounds
+ * (protocol 9). Live, because a map changes at every wipe.
+ */
+const worldMonuments = (server) => request(server, '/world/monuments')
+
+/**
+ * What the world still holds of what events made, looked for by net id on the
+ * game (a restart is not proof a crate is gone, §28.1). One run, or all.
+ */
+const worldOwned = (server, { runId } = {}) =>
+ request(server, `/world/owned${runId ? `?runId=${encodeURIComponent(runId)}` : ''}`)
+
+/**
+ * Open a zone, or place crates or NPCs, for a run. `data.kind` is `world.ok`
+ * (with `placed`) or `world.error` (with `reason`); a repeated idempotency key
+ * is answered with the first call's ids and `repeat: true`.
+ */
+const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
+const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
+
+/**
+ * Give back what a run owns: named ids, else everything under a key, else the
+ * whole run. `data` lists `removed`, `gone` (already not there — a success)
+ * and `refused` (there, and not this run's to erase).
+ */
+const worldRevert = (server, body) => request(server, '/world/revert', { method: 'POST', body })
+
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -352,5 +383,10 @@ module.exports = {
leaseList,
leaseApply,
leaseRelease,
+ worldMonuments,
+ worldOwned,
+ worldZone,
+ worldPlace,
+ worldRevert,
joinUrl,
}
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 0c2b576..537c5ec 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -142,10 +142,41 @@ test('nothing is registered that has nothing behind it yet', () => {
// NOT register (D62) moved into the assertions below. Phase 12 deleted the
// leases and option sources, and kept budgets here on purpose (D79): a lease
// spends none, and a dimension nothing spends is a dial that does nothing.
+ // Phase 13a deleted the budgets and actions lines, and registered each budget
+ // beside the verb that spends it (below). The announce leg is 13b's.
assert.deepStrictEqual(api.record.legs, [])
assert.strictEqual(api.record.hooks.post, undefined)
- assert.strictEqual(api.record.eventBudgets, null)
- assert.strictEqual(api.record.eventActions, null)
+})
+
+test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
+ const { api } = register()
+
+ const actions = api.record.eventActions
+ const budgets = api.record.eventBudgets
+ assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.prefab.place', 'rust.zone.open'])
+ assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
+
+ // D79/D89: no dimension without a verb that spends it. A crate step and an
+ // NPC step are priced on different dials, and a zone on its minutes.
+ const spent = new Set()
+ const place = actions.find((a) => a.id === 'rust.prefab.place')
+ for (const cost of [
+ place.cost({ prefab: 'crate.elite', count: 3 }),
+ place.cost({ prefab: 'npc.scientist', count: 2 }),
+ actions.find((a) => a.id === 'rust.zone.open').cost({ minutes: 90 }),
+ ]) {
+ for (const id of Object.keys(cost)) spent.add(id)
+ }
+ assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
+
+ for (const a of actions) {
+ assert.strictEqual(a.reversible, 'ledger')
+ assert.strictEqual(typeof a.revert, 'function')
+ assert.strictEqual(typeof a.reconcile, 'function')
+ // Every param source is one this module registers.
+ const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
+ for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
+ }
})
test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
@@ -158,8 +189,10 @@ test('the leases and their option sources are registered, every source a lease r
['rust.decay.scale', 'rust.group.permission', 'rust.population', 'rust.spawn.scalar'],
)
- // D78: exactly the sources the leases' targets name — none without a reader.
+ // D78: exactly the sources the leases' targets name, plus those the world
+ // verbs' params name (phase 13a) — none without a reader.
const read = new Set(leases.map((l) => l.target.source))
+ for (const a of api.record.eventActions) for (const p of a.params) if (p.source) read.add(p.source)
assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
for (const l of leases) {
diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js
index bf24bb8..9a06078 100644
--- a/server/test/permissions.test.js
+++ b/server/test/permissions.test.js
@@ -205,6 +205,43 @@ test('a permission the server could not resolve is not recorded as pushed', asyn
assert.ok(!recorded.includes('7656003'), 'a pending membership is not in the game yet')
})
+test('a grant the store did not hold after the plugin read it back is not recorded as pushed (D85)', 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: 'zonemanager.zone' },
+ { kind: 'group-permission', subject: 'vip', object: 'kits.vip' },
+ ],
+ }
+
+ // Protocol 9's read-back. The case it exists for is phase 7's owner bug: a
+ // grant the plugin made that never reached Oxide's store, which the site had
+ // been recording as pushed.
+ const report = {
+ kind: 'perm.report',
+ applied: { grants: 1 },
+ unresolved: [],
+ pending: [],
+ notLanded: ['7656001:ZoneManager.Zone', 'vip:kits.vip'],
+ foreign: [],
+ }
+
+ 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'))
+ const recorded = insert.params.join(' ')
+ assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed')
+ assert.ok(!recorded.includes('zonemanager.zone'), 'a grant that did not land is not, whatever its case')
+ assert.ok(!recorded.includes('kits.vip'), 'nor a group permission that did not land')
+})
+
test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => {
withCore()
const permSync = require('../permSync')
diff --git a/server/test/world.test.js b/server/test/world.test.js
new file mode 100644
index 0000000..6da7f04
--- /dev/null
+++ b/server/test/world.test.js
@@ -0,0 +1,325 @@
+// ── The world verbs (PLAN.md §28, protocol 9) ─────────────────────────────
+//
+// What an event makes and gives back. Every test here is one of the ways the
+// contract's half can look right and be wrong:
+//
+// the budget must outlive the client, or `retry: false` is unreachable
+// a location is a monument OR coordinates, and a monument names its server
+// a dry run checks everything it can and sends nothing
+// the ref carries the server, because revert and reconcile get no params
+// the undo carries NO idempotency key (module-uo's teardown-was-a-no-op bug)
+// a lost answer is reverted by key on every server, since the server is unknown
+// `gone` is a success and `refused` is not
+// "cannot ask" is never "it is gone"
+// only a restart or a wipe provokes a reconcile, never a reconnect
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+require('../core')._reset()
+require('../core').init(fakeCtx())
+
+const core = require('../core')
+const client = require('../sidecarClient')
+const serversDb = require('../model/servers/servers.db')
+const servers = require('../model/servers/servers.model')
+const world = require('../eventWorld')
+
+const action = (id) => world.ACTIONS.find((a) => a.id === id)
+const source = (id) => world.OPTION_SOURCES.find((s) => s.id === id)
+
+const ROWS = {
+ main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
+ alt: { id: 'alt', name: 'Alt', sidecarBaseUrl: 'http://alt:1', sidecarTokenEnc: null, enabled: 1 },
+ off: { id: 'off', name: 'Off', sidecarBaseUrl: 'http://off:1', sidecarTokenEnc: null, enabled: 0 },
+}
+
+/** Replace the module's collaborators for one test, and put them back after. */
+function stub(t, { zone, place, revert, owned, monuments, polling } = {}) {
+ const calls = { zone: [], place: [], revert: [], owned: [], monuments: [] }
+ const saved = {
+ getServer: serversDb.getServer,
+ listForPolling: servers.listForPolling,
+ worldZone: client.worldZone,
+ worldPlace: client.worldPlace,
+ worldRevert: client.worldRevert,
+ worldOwned: client.worldOwned,
+ worldMonuments: client.worldMonuments,
+ }
+
+ serversDb.getServer = async (id) => ROWS[id] || null
+ servers.listForPolling = async () =>
+ (polling || ['main']).map((id) => ({ id, name: ROWS[id] ? ROWS[id].name : id, baseUrl: `http://${id}:1`, token: 't' }))
+ const ok = (data) => ({ ok: true, status: 'ok', data })
+ client.worldZone = async (server, body) => {
+ calls.zone.push({ server: server.id, body })
+ return zone ? zone(server, body) : ok({ kind: 'world.ok', placed: [{ id: 'rg-7-1-1', kind: 'zone', name: 'Z' }] })
+ }
+ client.worldPlace = async (server, body) => {
+ calls.place.push({ server: server.id, body })
+ return place
+ ? place(server, body)
+ : ok({ kind: 'world.ok', placed: [{ id: '101', kind: 'crate', prefab: body.prefab }, { id: '102', kind: 'crate', prefab: body.prefab }] })
+ }
+ client.worldRevert = async (server, body) => {
+ calls.revert.push({ server: server.id, body })
+ return revert ? revert(server, body) : ok({ kind: 'world.ok', removed: body.ids || [], gone: [], refused: [] })
+ }
+ client.worldOwned = async (server, q) => {
+ calls.owned.push({ server: server.id, ...q })
+ return owned ? owned(server, q) : { ok: false, status: 'http-503' }
+ }
+ client.worldMonuments = async (server) => {
+ calls.monuments.push(server.id)
+ return monuments ? monuments(server) : { ok: false, status: 'http-503' }
+ }
+
+ t.after(() => Object.assign(client, {
+ worldZone: saved.worldZone,
+ worldPlace: saved.worldPlace,
+ worldRevert: saved.worldRevert,
+ worldOwned: saved.worldOwned,
+ worldMonuments: saved.worldMonuments,
+ }))
+ t.after(() => {
+ serversDb.getServer = saved.getServer
+ servers.listForPolling = saved.listForPolling
+ })
+
+ return calls
+}
+
+test('every world verb outlives the client, which outlives the sidecar', () => {
+ // `sidecar RPC (10s) < TIMEOUT_MS < budgetMs` — or the dispatcher gives up
+ // first, classifies retry, and every `retry: false` below is dead code.
+ assert.ok(10000 < client.TIMEOUT_MS)
+ for (const a of world.ACTIONS) assert.ok(client.TIMEOUT_MS < a.budgetMs, `${a.id} budgetMs`)
+})
+
+test('the mirrored bounds are the plugin\'s own (D95, D96)', () => {
+ assert.strictEqual(world.MAX_CRATES, 25)
+ assert.strictEqual(world.MAX_NPCS, 20)
+ assert.strictEqual(world.ZONE_MAX_MINUTES, 7 * 24 * 60)
+ // Crates and NPCs only, never a vehicle (D88).
+ assert.deepStrictEqual([...new Set(world.PLACEABLE.map((p) => p.kind))].sort(), ['crate', 'npc'])
+})
+
+test('a location is a monument or coordinates, exactly one, and a monument names its server', () => {
+ assert.strictEqual(world.location({}).ok, false)
+ assert.strictEqual(world.location({ monument: 'main/airfield_1', x: 1, z: 2 }).ok, false)
+
+ const byMonument = world.location({ monument: 'main/harbor_1#2', offsetX: 10 })
+ assert.deepStrictEqual(byMonument, { ok: true, serverId: 'main', wire: { monument: 'harbor_1#2', offsetX: 10, offsetZ: 0 } })
+
+ // Two ways to name a server must agree.
+ assert.match(world.location({ monument: 'main/harbor_1', server: 'alt' }).error, /on main, not alt/)
+ // An offset past the bound is refused on the form, not mid-run.
+ assert.match(world.location({ monument: 'main/harbor_1', offsetX: 120, offsetZ: 120 }).error, /at most 150/)
+
+ // Coordinates name nothing, so they need the server.
+ assert.match(world.location({ x: 1, z: 2 }).error, /which server/)
+ assert.deepStrictEqual(world.location({ x: 1, z: 2, server: 'alt' }), { ok: true, serverId: 'alt', wire: { x: 1, z: 2 } })
+ assert.match(world.location({ x: 1, server: 'alt' }).error, /both x and z/)
+})
+
+test('a dry run checks everything it can and sends nothing', async (t) => {
+ const calls = stub(t)
+ const zone = await action('rust.zone.open').perform({
+ runId: 7, idempotencyKey: 'k1', verify: true, params: { monument: 'main/airfield_1', radius: 40, minutes: 60 },
+ })
+ const place = await action('rust.prefab.place').perform({
+ runId: 7, idempotencyKey: 'k2', verify: true, params: { monument: 'main/airfield_1', prefab: 'crate.elite', count: 3 },
+ })
+ assert.deepStrictEqual([zone, place], [{ ok: true }, { ok: true }])
+ assert.deepStrictEqual([calls.zone.length, calls.place.length], [0, 0])
+})
+
+test('every authoring mistake is refused for good, before anything is sent', async (t) => {
+ const calls = stub(t)
+ const place = action('rust.prefab.place')
+ const zone = action('rust.zone.open')
+ const run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params })
+
+ for (const result of [
+ await run(place, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
+ await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
+ await run(place, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
+ await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }),
+ await run(zone, { monument: 'main/a', radius: 4, minutes: 10 }),
+ await run(zone, { monument: 'main/a', radius: 40 }), // D96: minutes are required
+ await run(zone, { monument: 'main/a', radius: 40, minutes: 7 * 24 * 60 + 1 }),
+ await run(zone, { monument: 'nowhere/a', radius: 40, minutes: 10 }),
+ await run(zone, { monument: 'off/a', radius: 40, minutes: 10 }),
+ ]) {
+ assert.strictEqual(result.ok, false)
+ assert.strictEqual(result.retry, false, result.error)
+ }
+ assert.deepStrictEqual([calls.zone.length, calls.place.length], [0, 0])
+})
+
+test('a zone crosses with its key, its duration, and the monument without the server', async (t) => {
+ const calls = stub(t)
+ const result = await action('rust.zone.open').perform({
+ runId: 7, idempotencyKey: 'k1', params: { monument: 'main/airfield_1', offsetZ: -20, radius: 40, minutes: 90, name: 'Brawl' },
+ })
+
+ assert.deepStrictEqual(calls.zone[0], {
+ server: 'main',
+ body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' },
+ })
+ // The ref carries the server: revert and reconcile are handed no params.
+ assert.deepStrictEqual(result.resources, [
+ { kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } },
+ ])
+})
+
+test('one resource per thing placed, and a repeated key is said to be one', async (t) => {
+ stub(t, {
+ place: async () => ({
+ ok: true,
+ data: { kind: 'world.ok', repeat: true, placed: [{ id: '101', kind: 'npc', prefab: 'npc.scientist' }] },
+ }),
+ })
+ const result = await action('rust.prefab.place').perform({
+ runId: 7, idempotencyKey: 'k', params: { x: -604, z: -342, server: 'main', prefab: 'npc.scientist', count: 1 },
+ })
+ assert.strictEqual(result.ok, true)
+ assert.deepStrictEqual(result.resources.map((r) => r.ref), ['main:101'])
+ assert.strictEqual(result.detail.repeat, true)
+})
+
+test('the switch being off is a refusal with the switch named, for good (D94)', async (t) => {
+ stub(t, {
+ place: async () => ({ ok: true, data: { kind: 'world.error', reason: 'events-disabled', message: 'set EventsEnabled' } }),
+ })
+ const result = await action('rust.prefab.place').perform({
+ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
+ })
+ assert.deepStrictEqual(result, { ok: false, retry: false, error: 'set EventsEnabled' })
+})
+
+test('a game that is down or slow is left to core to retry', async (t) => {
+ stub(t, { place: async () => ({ ok: false, status: 'http-503' }) })
+ const result = await action('rust.prefab.place').perform({
+ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
+ })
+ assert.strictEqual(result.ok, false)
+ assert.strictEqual(result.retry, undefined)
+ assert.match(result.error, /Main has no game connected/)
+})
+
+test('revert sends ids per server and NO idempotency key', async (t) => {
+ const calls = stub(t)
+ const result = await world.revert({
+ runId: 7,
+ idempotencyKey: 'k-of-the-do',
+ resources: [
+ { kind: 'world', ref: 'main:101', payload: { serverId: 'main' } },
+ { kind: 'world', ref: 'alt:rg-7-1-1', payload: { serverId: 'alt' } },
+ { kind: 'world', ref: 'main:102' },
+ ],
+ })
+ assert.deepStrictEqual(result, { ok: true })
+ assert.deepStrictEqual(calls.revert, [
+ { server: 'main', body: { runId: '7', ids: ['101', '102'] } },
+ { server: 'alt', body: { runId: '7', ids: ['rg-7-1-1'] } },
+ ])
+})
+
+test('gone is a success, and refused is a failure named by ref', async (t) => {
+ stub(t, { revert: async () => ({ ok: true, data: { kind: 'world.ok', removed: ['101'], gone: ['102'], refused: ['103'] } }) })
+ const result = await world.revert({
+ runId: 7,
+ resources: ['101', '102', '103'].map((id) => ({ kind: 'world', ref: `main:${id}` })),
+ })
+ assert.deepStrictEqual(result, { ok: true, failed: ['main:103'] })
+})
+
+test('a lost answer is reverted by its key on every server, and an unreachable one keeps the row', async (t) => {
+ const calls = stub(t, {
+ polling: ['main', 'alt'],
+ revert: async (server) => (server.id === 'alt' ? { ok: false, status: 'http-503' } : { ok: true, data: { kind: 'world.ok' } }),
+ })
+ const result = await world.revert({ runId: 7, resources: [], idempotencyKey: 'k-lost' })
+ assert.deepStrictEqual(calls.revert.map((c) => c.body), [
+ { runId: '7', key: 'k-lost' },
+ { runId: '7', key: 'k-lost' },
+ ])
+ assert.strictEqual(result.ok, false)
+ assert.match(result.error, /Alt has no game connected/)
+})
+
+test('reconcile asks each server what it holds, and "cannot ask" is not "gone"', async (t) => {
+ stub(t, {
+ owned: async (server) =>
+ server.id === 'main'
+ ? { ok: true, data: { kind: 'world.owned', owned: [{ id: '101' }] } }
+ : { ok: false, status: 'http-503' },
+ })
+ const result = await world.reconcile({
+ runId: 7,
+ resources: [
+ { kind: 'world', ref: 'main:101' },
+ { kind: 'world', ref: 'main:102' }, // looted, or an NPC a restart took
+ { kind: 'world', ref: 'alt:rg-7-1-1', payload: { serverId: 'alt' } },
+ ],
+ })
+ assert.deepStrictEqual(result, { ok: true, inForce: ['main:101', 'alt:rg-7-1-1'] })
+})
+
+test('the monument source lists each server\'s map as whole values, numbered where a kind repeats', async (t) => {
+ stub(t, {
+ polling: ['main', 'alt'],
+ monuments: async (server) =>
+ server.id === 'alt'
+ ? { ok: false, status: 'http-503' }
+ : {
+ ok: true,
+ data: {
+ monuments: [
+ { value: 'harbor_1#1', label: 'Harbor', instance: 1, of: 2, grid: 'M7' },
+ { value: 'harbor_1#2', label: 'Harbor', instance: 2, of: 2, grid: 'C12' },
+ { value: 'powerplant_1', label: 'Power Plant', instance: 1, of: 1, grid: 'F14' },
+ ],
+ },
+ },
+ })
+ const rows = await source('rust.options.monuments').resolve()
+ assert.deepStrictEqual(rows, [
+ { value: 'main/harbor_1#1', label: 'Harbor #1 · M7', group: 'Main' },
+ { value: 'main/harbor_1#2', label: 'Harbor #2 · C12', group: 'Main' },
+ { value: 'main/powerplant_1', label: 'Power Plant · F14', group: 'Main' },
+ ])
+ const narrowed = await source('rust.options.monuments').resolve({ q: 'power' })
+ assert.deepStrictEqual(narrowed.map((r) => r.value), ['main/powerplant_1'])
+})
+
+test('the prefab source answers with every server off', async (t) => {
+ const calls = stub(t)
+ const rows = await source('rust.options.prefabs').resolve()
+ assert.strictEqual(rows.length, world.PLACEABLE.length)
+ assert.deepStrictEqual(calls.monuments, [])
+})
+
+test('only a restart or a wipe asks core to reconcile — never a first sighting or a reconnect', (t) => {
+ world.resetWatch()
+ let asked = 0
+ const saved = core.reconcileEvents
+ core.reconcileEvents = () => {
+ asked += 1
+ return Promise.resolve({})
+ }
+ t.after(() => {
+ core.reconcileEvents = saved
+ world.resetWatch()
+ })
+
+ assert.strictEqual(world.observeServer('main', { bootId: 'b1', wipeId: 'w1' }), false) // baseline
+ assert.strictEqual(world.observeServer('main', { bootId: 'b1', wipeId: 'w1' }), false) // a reconnect
+ assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1' }), true) // a restart
+ assert.strictEqual(world.observeServer('main', { bootId: 'b3', wipeId: 'w2' }), true) // a wipe
+ assert.strictEqual(world.observeServer('alt', { bootId: 'x', wipeId: 'y' }), false) // another server's baseline
+ assert.strictEqual(asked, 2)
+})
--
2.49.1
From fab31f23e864d83a77b492675bc7915cc9b5a4c6 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Thu, 24 Sep 2026 02:09:49 -0500
Subject: [PATCH 16/51] =?UTF-8?q?refactor(rust):=20one=20placing=20verb=20?=
=?UTF-8?q?per=20kind=20=E2=80=94=20rust.crate.place=20and=20rust.npc.plac?=
=?UTF-8?q?e=20(D97)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Core learns which caps an action accepts by pricing its declared examples
once, and drops a dimension priced at zero. A single rust.prefab.place whose
cost moved between rust.prefabs and rust.npcs by its prefab param could only
ever show the crates cap, so D89's separate dial for fights was unreachable.
Two verbs, each pricing exactly one dimension, with the prefab source split
to match (rust.options.crates / rust.options.npcs). The switchboard can now
allow crates and leave NPCs off. The plugin's world.place is unchanged.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
server/eventWorld.js | 204 ++++++++++++++++++++++----------------
server/test/entry.test.js | 20 ++--
server/test/world.test.js | 30 +++---
3 files changed, 144 insertions(+), 110 deletions(-)
diff --git a/server/eventWorld.js b/server/eventWorld.js
index 7f38226..16d97f1 100644
--- a/server/eventWorld.js
+++ b/server/eventWorld.js
@@ -1,7 +1,7 @@
// ── What an event MAKES on a Rust server (PLAN.md §28, protocol 9) ────────
//
-// A lease borrows a value that was already there. These two verbs make
-// something that was not — a zone, and crates or NPCs placed in the world — and
+// A lease borrows a value that was already there. These three verbs make
+// something that was not — a zone, crates, NPCs — and
// give it back at teardown. Everything that decides what is allowed lives on the
// plugin: the allowlist, the bounds, the monument vocabulary, the registry of
// what each run owns. What is here is the contract's half: declarations core can
@@ -372,6 +372,91 @@ const WORLD_COMMON = {
reconcile,
}
+/**
+ * One placing verb per KIND (D97), not one verb for both.
+ *
+ * Core learns which caps an action accepts by pricing that action's declared
+ * EXAMPLES once, and drops a dimension priced at zero. So a single verb whose
+ * cost moved between `rust.prefabs` and `rust.npcs` by its `prefab` param could
+ * only ever show the operator the crates cap, and D89's separate dial for fights
+ * would be unreachable. Two verbs, each pricing exactly one dimension, is also
+ * what lets the switchboard allow crates and leave NPCs off.
+ */
+function placeVerb({ id, kind, budget, max, label, description, source, example }) {
+ const noun = kind === 'npc' ? 'NPCs' : 'crates'
+ return {
+ ...WORLD_COMMON,
+ id,
+ label,
+ description,
+ cost: (p) => ({ [budget]: Math.max(0, Math.round(Number(p.count) || 0)) }),
+ params: [
+ {
+ name: 'prefab',
+ type: 'string',
+ required: true,
+ example,
+ source,
+ description: `Which of the server's own ${noun} to place.`,
+ },
+ {
+ name: 'count',
+ type: 'int',
+ required: true,
+ example: 3,
+ description: `How many — 1 to ${max} at a time.`,
+ },
+ {
+ name: 'spread',
+ type: 'float',
+ required: false,
+ example: 10,
+ description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
+ },
+ ...LOCATION_PARAMS,
+ ],
+
+ async perform({ runId, idempotencyKey, params, verify }) {
+ const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
+ if (!known || known.kind !== kind) {
+ return { ok: false, retry: false, error: `"${params.prefab}" is not one of the ${noun} a Rust server places for events` }
+ }
+
+ const count = Number(params.count)
+ if (!Number.isInteger(count) || count < 1 || count > max) {
+ return { ok: false, retry: false, error: `place 1 to ${max} ${noun} at a time, and "${params.count}" is not that` }
+ }
+
+ const spread = num(params.spread)
+ if (Number.isNaN(spread) || (spread !== undefined && (spread < 0 || spread > MAX_SPREAD))) {
+ return { ok: false, retry: false, error: `a scatter is 0 to ${MAX_SPREAD} m, not "${params.spread}"` }
+ }
+
+ const where = location(params)
+ if (!where.ok) return { ok: false, retry: false, error: where.error }
+
+ const found = await serverFor(where.serverId)
+ if (!found.ok) return found
+
+ if (verify) return { ok: true }
+
+ return place(
+ found.server,
+ client.worldPlace,
+ {
+ runId: String(runId),
+ key: idempotencyKey,
+ prefab: known.key,
+ count,
+ ...(spread === undefined ? {} : { spread }),
+ ...where.wire,
+ },
+ known.label.toLowerCase(),
+ )
+ },
+ }
+}
+
const ACTIONS = [
{
...WORLD_COMMON,
@@ -436,85 +521,28 @@ const ACTIONS = [
)
},
},
- {
- ...WORLD_COMMON,
- id: 'rust.prefab.place',
- label: 'Place crates or NPCs',
+ placeVerb({
+ id: 'rust.crate.place',
+ kind: 'crate',
+ budget: 'rust.prefabs',
+ max: MAX_CRATES,
+ label: 'Place crates',
description:
- 'Crates, a supply drop or NPCs at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.',
- cost: (p) => {
- const known = PLACEABLE.find((x) => x.key === String(p.prefab || '').trim())
- const count = Math.max(0, Math.round(Number(p.count) || 0))
- return { [known && known.kind === 'npc' ? 'rust.npcs' : 'rust.prefabs']: count }
- },
- params: [
- {
- name: 'prefab',
- type: 'string',
- required: true,
- example: 'crate.elite',
- source: 'rust.options.prefabs',
- description: "What to place. The list is the server's own allowlist: crates and NPCs, never vehicles.",
- },
- {
- name: 'count',
- type: 'int',
- required: true,
- example: 3,
- description: `How many — up to ${MAX_CRATES} crates or ${MAX_NPCS} NPCs at a time.`,
- },
- {
- name: 'spread',
- type: 'float',
- required: false,
- example: 10,
- description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
- },
- ...LOCATION_PARAMS,
- ],
-
- async perform({ runId, idempotencyKey, params, verify }) {
- const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
- if (!known) return { ok: false, retry: false, error: `"${params.prefab}" is not something a Rust server places for events` }
-
- const max = known.kind === 'npc' ? MAX_NPCS : MAX_CRATES
- const count = Number(params.count)
- if (!Number.isInteger(count) || count < 1 || count > max) {
- return {
- ok: false,
- retry: false,
- error: `place 1 to ${max} ${known.kind === 'npc' ? 'NPCs' : 'crates'} at a time, and "${params.count}" is not that`,
- }
- }
-
- const spread = num(params.spread)
- if (Number.isNaN(spread) || (spread !== undefined && (spread < 0 || spread > MAX_SPREAD))) {
- return { ok: false, retry: false, error: `a scatter is 0 to ${MAX_SPREAD} m, not "${params.spread}"` }
- }
-
- const where = location(params)
- if (!where.ok) return { ok: false, retry: false, error: where.error }
-
- const found = await serverFor(where.serverId)
- if (!found.ok) return found
-
- if (verify) return { ok: true }
-
- return place(
- found.server,
- client.worldPlace,
- {
- runId: String(runId),
- key: idempotencyKey,
- prefab: known.key,
- count,
- ...(spread === undefined ? {} : { spread }),
- ...where.wire,
- },
- known.label.toLowerCase(),
- )
- },
- },
+ 'Crates, barrels or a supply drop at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.',
+ source: 'rust.options.crates',
+ example: 'crate.elite',
+ }),
+ placeVerb({
+ id: 'rust.npc.place',
+ kind: 'npc',
+ budget: 'rust.npcs',
+ max: MAX_NPCS,
+ label: 'Place NPCs',
+ description:
+ 'Scientists or guards at a monument or a point. Taken away at teardown. The game does not save NPCs, so a restart ends them; the ledger then says so.',
+ source: 'rust.options.npcs',
+ example: 'npc.scientist',
+ }),
]
const OPTION_SOURCES = [
@@ -541,16 +569,16 @@ const OPTION_SOURCES = [
return bounded(rows, 'rust.options.monuments')
},
},
- {
- // From the mirror, so it answers with every server off (the field it fills
- // must never be taken away by an outage, MODULE_API §2.4).
- id: 'rust.options.prefabs',
- label: 'Things to place',
- description: 'Crates and NPCs a Rust server places for events.',
+ // From the mirror, so both answer with every server off (the field they fill
+ // must never be taken away by an outage, MODULE_API §2.4). One per verb (D97).
+ ...['crate', 'npc'].map((kind) => ({
+ id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
+ label: kind === 'npc' ? 'NPCs' : 'Crates',
+ description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`,
async resolve() {
- return PLACEABLE.map((p) => ({ value: p.key, label: p.label, group: p.kind === 'npc' ? 'NPCs' : 'Crates' }))
+ return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
},
- },
+ })),
]
// ── The watch (§11.1) ───────────────────────────────────────────────────────
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 537c5ec..2f7f0fe 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -153,19 +153,19 @@ test('the world verbs are registered, and every budget has a verb that spends it
const actions = api.record.eventActions
const budgets = api.record.eventBudgets
- assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.prefab.place', 'rust.zone.open'])
+ assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.crate.place', 'rust.npc.place', 'rust.zone.open'])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
- // D79/D89: no dimension without a verb that spends it. A crate step and an
- // NPC step are priced on different dials, and a zone on its minutes.
+ // D79/D89/D97: every dimension has a verb that spends it, and each verb spends
+ // exactly ONE — priced from its own declared examples, which is how core
+ // decides which cap boxes the switchboard shows. A verb whose dimension moved
+ // with its params would hide the other dial from every operator.
const spent = new Set()
- const place = actions.find((a) => a.id === 'rust.prefab.place')
- for (const cost of [
- place.cost({ prefab: 'crate.elite', count: 3 }),
- place.cost({ prefab: 'npc.scientist', count: 2 }),
- actions.find((a) => a.id === 'rust.zone.open').cost({ minutes: 90 }),
- ]) {
- for (const id of Object.keys(cost)) spent.add(id)
+ for (const a of actions) {
+ const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
+ const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
+ assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
+ spent.add(dims[0])
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
diff --git a/server/test/world.test.js b/server/test/world.test.js
index 6da7f04..9b16339 100644
--- a/server/test/world.test.js
+++ b/server/test/world.test.js
@@ -129,7 +129,7 @@ test('a dry run checks everything it can and sends nothing', async (t) => {
const zone = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k1', verify: true, params: { monument: 'main/airfield_1', radius: 40, minutes: 60 },
})
- const place = await action('rust.prefab.place').perform({
+ const place = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k2', verify: true, params: { monument: 'main/airfield_1', prefab: 'crate.elite', count: 3 },
})
assert.deepStrictEqual([zone, place], [{ ok: true }, { ok: true }])
@@ -138,15 +138,18 @@ test('a dry run checks everything it can and sends nothing', async (t) => {
test('every authoring mistake is refused for good, before anything is sent', async (t) => {
const calls = stub(t)
- const place = action('rust.prefab.place')
+ const crates = action('rust.crate.place')
+ const npcs = action('rust.npc.place')
const zone = action('rust.zone.open')
const run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params })
for (const result of [
- await run(place, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
- await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
- await run(place, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
- await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }),
+ await run(crates, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
+ await run(crates, { monument: 'main/a', prefab: 'npc.scientist', count: 1 }), // D97: the other verb's
+ await run(npcs, { monument: 'main/a', prefab: 'crate.elite', count: 1 }),
+ await run(crates, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
+ await run(npcs, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
+ await run(crates, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }),
await run(zone, { monument: 'main/a', radius: 4, minutes: 10 }),
await run(zone, { monument: 'main/a', radius: 40 }), // D96: minutes are required
await run(zone, { monument: 'main/a', radius: 40, minutes: 7 * 24 * 60 + 1 }),
@@ -182,7 +185,7 @@ test('one resource per thing placed, and a repeated key is said to be one', asyn
data: { kind: 'world.ok', repeat: true, placed: [{ id: '101', kind: 'npc', prefab: 'npc.scientist' }] },
}),
})
- const result = await action('rust.prefab.place').perform({
+ const result = await action('rust.npc.place').perform({
runId: 7, idempotencyKey: 'k', params: { x: -604, z: -342, server: 'main', prefab: 'npc.scientist', count: 1 },
})
assert.strictEqual(result.ok, true)
@@ -194,7 +197,7 @@ test('the switch being off is a refusal with the switch named, for good (D94)',
stub(t, {
place: async () => ({ ok: true, data: { kind: 'world.error', reason: 'events-disabled', message: 'set EventsEnabled' } }),
})
- const result = await action('rust.prefab.place').perform({
+ const result = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.deepStrictEqual(result, { ok: false, retry: false, error: 'set EventsEnabled' })
@@ -202,7 +205,7 @@ test('the switch being off is a refusal with the switch named, for good (D94)',
test('a game that is down or slow is left to core to retry', async (t) => {
stub(t, { place: async () => ({ ok: false, status: 'http-503' }) })
- const result = await action('rust.prefab.place').perform({
+ const result = await action('rust.crate.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.strictEqual(result.ok, false)
@@ -296,10 +299,13 @@ test('the monument source lists each server\'s map as whole values, numbered whe
assert.deepStrictEqual(narrowed.map((r) => r.value), ['main/powerplant_1'])
})
-test('the prefab source answers with every server off', async (t) => {
+test('the crate and NPC sources split the allowlist, and answer with every server off (D97)', async (t) => {
const calls = stub(t)
- const rows = await source('rust.options.prefabs').resolve()
- assert.strictEqual(rows.length, world.PLACEABLE.length)
+ const crates = await source('rust.options.crates').resolve()
+ const npcs = await source('rust.options.npcs').resolve()
+ assert.strictEqual(crates.length + npcs.length, world.PLACEABLE.length)
+ assert.ok(crates.every((r) => !r.value.startsWith('npc.')))
+ assert.ok(npcs.every((r) => r.value.startsWith('npc.')))
assert.deepStrictEqual(calls.monuments, [])
})
--
2.49.1
From ba636c8939524728cfd050fcef10d63420475202 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Thu, 24 Sep 2026 02:16:03 -0500
Subject: [PATCH 17/51] fix(rust): hold the reconcile until the world is
loaded, and never read a refusal as a revert
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two defects the phase 13a walk found by restarting the rig mid-run:
- The watch asked core to reconcile the moment a new boot id appeared, which
is before the game has loaded its save — every crate looked gone and was
orphaned. It now waits for the plugin's hello to say `worldReady`; an older
plugin that never says is taken as ready.
- revert() read any 200 as success. On this bridge a refusal is a 200
carrying world.error (`not-ready` while loading), so every row would have
been marked reverted with the game still holding every crate.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
server/boot.js | 4 +++-
server/eventWorld.js | 22 ++++++++++++++++++++--
server/test/world.test.js | 36 ++++++++++++++++++++++++++++++++++++
3 files changed, 59 insertions(+), 3 deletions(-)
diff --git a/server/boot.js b/server/boot.js
index af4770c..193fc25 100644
--- a/server/boot.js
+++ b/server/boot.js
@@ -185,7 +185,9 @@ async function refreshOne(server) {
// A restart or a wipe under a running event is the moment core must be told
// to ask what the world still holds (§11.1). Only a CONNECTED plugin's hello
// counts: a board the game left behind says nothing about now.
- if (connected) eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId })
+ if (connected) {
+ eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId, worldReady: frame.worldReady })
+ }
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`
// as a rejection that hides which one. Log with the id and carry on.
diff --git a/server/eventWorld.js b/server/eventWorld.js
index 16d97f1..e869074 100644
--- a/server/eventWorld.js
+++ b/server/eventWorld.js
@@ -256,6 +256,7 @@ async function revert({ runId, resources, idempotencyKey }) {
for (const server of await servers.listForPolling()) {
const result = await client.worldRevert(server, { runId: String(runId), key: idempotencyKey })
if (!result.ok) errors.push(transportError(server, result, 'revert'))
+ else if (!result.data || result.data.kind !== 'world.ok') errors.push(pluginError(result.data, `${server.name || server.id} refused the revert`))
}
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
}
@@ -279,6 +280,16 @@ async function revert({ runId, resources, idempotencyKey }) {
continue
}
+ // **A 200 is not a success on this bridge** — a refusal comes back as one,
+ // carrying `world.error` (`not-ready` while the world is still loading).
+ // Read as success it would mark every row reverted while the game still
+ // held every crate.
+ if (!result.data || result.data.kind !== 'world.ok') {
+ failed.push(...group.map((r) => r.ref))
+ errors.push(pluginError(result.data, `${found.server.name || found.server.id} refused the revert`))
+ continue
+ }
+
// `gone` is not reported: a crate a player looted is the point of having
// placed it. `refused` IS — the plugin found something there that this run
// did not make, and nothing will ever remove it through this path.
@@ -593,12 +604,19 @@ const lastSeen = new Map()
/**
* Note a server's identity as the refresh saw it, and ask core to reconcile when
- * it moved. The first sighting after this module boots is a baseline, not a
+ * it moved — once its world is loaded. The first sighting after this module boots is a baseline, not a
* change: core's own boot reconcile already covered it.
*/
-function observeServer(serverId, { bootId, wipeId } = {}) {
+function observeServer(serverId, { bootId, wipeId, worldReady } = {}) {
if (!serverId || (!bootId && !wipeId)) return false
+ // **Not until the world is loaded** (§28.6). The plugin connects before the
+ // save loads, so the new boot id arrives while every crate still looks gone;
+ // asked then, reconcile would orphan the lot. The plugin says when it is
+ // ready, and the change is noticed on that hello instead. An older plugin
+ // that never says is taken as ready, as it always was.
+ if (worldReady === false) return false
+
const previous = lastSeen.get(serverId)
lastSeen.set(serverId, { bootId: bootId || null, wipeId: wipeId || null })
if (!previous) return false
diff --git a/server/test/world.test.js b/server/test/world.test.js
index 9b16339..a66c8dd 100644
--- a/server/test/world.test.js
+++ b/server/test/world.test.js
@@ -329,3 +329,39 @@ test('only a restart or a wipe asks core to reconcile — never a first sighting
assert.strictEqual(world.observeServer('alt', { bootId: 'x', wipeId: 'y' }), false) // another server's baseline
assert.strictEqual(asked, 2)
})
+
+test('a refusal that arrives as a 200 is not a revert (§28.6)', async (t) => {
+ // The plugin answers `world.error` with a 200 — `not-ready` while the world is
+ // still loading. Read as success, every row would be marked reverted while the
+ // game still held every crate.
+ stub(t, {
+ revert: async () => ({ ok: true, data: { kind: 'world.error', reason: 'not-ready', message: 'still loading' } }),
+ })
+ const result = await world.revert({ runId: 8, resources: [{ kind: 'world', ref: 'main:101' }] })
+ assert.deepStrictEqual(result, { ok: false, error: 'still loading' })
+
+ const lost = await world.revert({ runId: 8, resources: [], idempotencyKey: 'k' })
+ assert.strictEqual(lost.ok, false)
+})
+
+test('a boot id seen before the world has loaded is not a restart yet (§28.6)', (t) => {
+ world.resetWatch()
+ let asked = 0
+ const saved = core.reconcileEvents
+ core.reconcileEvents = () => {
+ asked += 1
+ return Promise.resolve({})
+ }
+ t.after(() => {
+ core.reconcileEvents = saved
+ world.resetWatch()
+ })
+
+ world.observeServer('main', { bootId: 'b1', wipeId: 'w1', worldReady: true })
+ // The plugin reconnects on the new boot BEFORE the save loads.
+ assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1', worldReady: false }), false)
+ assert.strictEqual(asked, 0)
+ // Its next hello says the world is there, and that is when the change counts.
+ assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1', worldReady: true }), true)
+ assert.strictEqual(asked, 1)
+})
--
2.49.1
From cc185db26babb5bbe4b1ceb8a06831e1bdd19e8a Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Thu, 24 Sep 2026 07:00:44 -0500
Subject: [PATCH 18/51] =?UTF-8?q?feat(rust):=20the=20rewards=20=E2=80=94?=
=?UTF-8?q?=20tally,=20kit=20reward,=20chat=20and=20the=20news=20leg=20(ph?=
=?UTF-8?q?ase=2013b,=20protocol=2010)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four event verbs and the announce leg, per PLAN.md §29:
- rust.participation.open / .collect: the plugin counts who takes part
(seconds, kills or both, in a zone this run opened or the whole server)
and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
rust_perm_run_grants (D84) unioned into the permission push, one extra
use of the kit per reward as site-held credits on perm.sync (D103),
and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
is on (D104) - a card on Admin -> Rust visibility (D106).
Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
ci/bundle.json | 1 +
client/src/routes/admin/Visibility.jsx | 50 +-
engagement-triggers.json | 97 ++
server/db/purge.sql | 3 +
server/db/schema.sql | 48 +
server/engagement/emit.js | 31 +
server/engagement/seeds.js | 21 +-
server/engagement/triggers.js | 35 +-
server/eventRewards.js | 854 ++++++++++++++++++
server/index.js | 44 +-
server/model/permissions/permissions.db.js | 77 ++
server/model/permissions/permissions.model.js | 68 +-
server/model/servers/servers.db.js | 3 +-
server/model/servers/servers.model.js | 24 +-
server/model/visibility/visibility.db.js | 12 +-
server/model/visibility/visibility.model.js | 24 +-
server/permSync.js | 4 +
server/router/admin/visibility.controller.js | 4 +-
server/router/admin/visibility.router.js | 5 +-
server/sidecarClient.js | 33 +-
server/swagger/doc.js | 24 +
server/test/engagement.test.js | 5 +-
server/test/entry.test.js | 44 +-
server/test/permissions.test.js | 58 ++
server/test/rewards.test.js | 419 +++++++++
server/test/visibility.test.js | 25 +
swagger-fragment.json | 128 ++-
27 files changed, 2096 insertions(+), 45 deletions(-)
create mode 100644 server/eventRewards.js
create mode 100644 server/test/rewards.test.js
diff --git a/ci/bundle.json b/ci/bundle.json
index c5d0c57..3df9ecf 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -34,6 +34,7 @@
"db",
"engagement",
"eventLeases.js",
+ "eventRewards.js",
"eventWorld.js",
"index.js",
"ingest.js",
diff --git a/client/src/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx
index 428d2fc..041018e 100644
--- a/client/src/routes/admin/Visibility.jsx
+++ b/client/src/routes/admin/Visibility.jsx
@@ -19,6 +19,12 @@
// lists each server's clan board: a server whose clans cannot be read, one at
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
// whose clans are a separate system and never Teams (D47).
+//
+// Phase 13b adds a third (D104, D106): whether a published news post is also
+// said in each server's in-game chat. Off by default, because core sends every
+// post to every registered leg — without a switch, the day this module updated,
+// every post would start appearing in every server's chat. It lives here because
+// this is the one page that lists every server with a setting of its own.
import { useCallback, useEffect, useState } from 'react'
@@ -87,6 +93,7 @@ export default function Visibility() {
const [fleet, setFleet] = useState('staff')
const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
+ const [news, setNews] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
@@ -98,6 +105,7 @@ export default function Visibility() {
setFleet(state.presence.fleet)
setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
+ setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
}, [])
useEffect(() => {
@@ -114,7 +122,9 @@ export default function Visibility() {
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
const dirtyClans = clanRoster !== clans.roster
- const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans
+ const newsRows = (data.news && data.news.servers) || []
+ const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
+ const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
@@ -131,6 +141,7 @@ export default function Visibility() {
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
+ if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
load(await api.adminVisibility.save(body))
setSaved(true)
setReloads((n) => n + 1)
@@ -225,6 +236,43 @@ export default function Visibility() {
+
+
+ When a news post is published, its title is said in the chat of every server switched on
+ here. A server that is down when a post is published is skipped rather than told late.
+
+ {newsRows.length === 0 && (
+
No servers are configured yet.
+ )}
+ {newsRows.map((s) => (
+
+ ))}
+
+
{busy ? 'Saving…' : 'Save'}
diff --git a/engagement-triggers.json b/engagement-triggers.json
index 859e228..e3a9027 100644
--- a/engagement-triggers.json
+++ b/engagement-triggers.json
@@ -316,6 +316,81 @@
}
]
},
+ {
+ "id": "rust.kit.entitled",
+ "label": "An event rewarded you a kit",
+ "description": "An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.",
+ "kind": "event",
+ "subjectKey": "rewardKey",
+ "audience": "owner",
+ "ceiling": "owner",
+ "version": 1,
+ "variables": [
+ {
+ "name": "title",
+ "type": "string",
+ "required": false,
+ "example": "Main is back online",
+ "description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
+ },
+ {
+ "name": "intro",
+ "type": "string",
+ "required": false,
+ "example": "Main is back up and taking players.",
+ "description": "One sentence of detail. Core generic bodies use it as the body."
+ },
+ {
+ "name": "rewardKey",
+ "type": "string",
+ "required": true,
+ "example": "41:7",
+ "description": "The run and step that awarded it. The cooldown subject; not meant for display."
+ },
+ {
+ "name": "kit",
+ "type": "string",
+ "required": true,
+ "example": "vip-starter",
+ "description": "The kit name, as the server Kits plugin has it."
+ },
+ {
+ "name": "serverId",
+ "type": "string",
+ "required": true,
+ "example": "main",
+ "description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
+ },
+ {
+ "name": "server",
+ "type": "string",
+ "required": true,
+ "example": "Runic Gateway | Main",
+ "description": "The server's display name."
+ },
+ {
+ "name": "serverUrl",
+ "type": "url",
+ "required": false,
+ "example": "/rust/servers/main",
+ "description": "Site-relative path to the server's page."
+ },
+ {
+ "name": "mode",
+ "type": "string",
+ "required": false,
+ "example": "top",
+ "description": "How the recipients were chosen: everyone, top, minScore, random or topPercent."
+ },
+ {
+ "name": "accountUrl",
+ "type": "url",
+ "required": false,
+ "example": "/player/rust",
+ "description": "Site-relative path to your Rust account page."
+ }
+ ]
+ },
{
"id": "rust.leaderboard.topped",
"label": "A new kills leader",
@@ -1059,6 +1134,28 @@
}
]
},
+ {
+ "key": "rewards-v1",
+ "rules": [
+ {
+ "trigger_id": "rust.kit.entitled",
+ "audience": "owner",
+ "channels": [
+ "email",
+ "inapp"
+ ],
+ "template_keys": {
+ "email": "notify.event",
+ "inapp": "inapp.event",
+ "digest": "notify.digest"
+ },
+ "conditions": null,
+ "cooldown_seconds": 0,
+ "delay_seconds": 0,
+ "cancel_on": []
+ }
+ ]
+ },
{
"key": "clans-v1",
"rules": [
diff --git a/server/db/purge.sql b/server/db/purge.sql
index deec5e9..2dfd9c4 100644
--- a/server/db/purge.sql
+++ b/server/db/purge.sql
@@ -19,6 +19,9 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
+-- Phase 13b.
+DROP TABLE IF EXISTS rust_perm_run_grants;
+
-- Phase 7b.
DROP TABLE IF EXISTS rust_clan_boards;
DROP TABLE IF EXISTS rust_clan_members;
diff --git a/server/db/schema.sql b/server/db/schema.sql
index cb7f68a..8fd8456 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -792,3 +792,51 @@ CREATE TABLE IF NOT EXISTS rust_clan_boards (
CONSTRAINT fk_rust_clan_boards_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+
+-- ── What an event granted (phase 13b, protocol 10) ────────────────────────
+--
+-- `rust.kit.entitle`'s ledger: one row per run, step, website user and
+-- permission (PLAN.md §29, D84). It is a table of its own, and not rows in
+-- `rust_perm_grants`, because that table is UNIQUE on (user, permission,
+-- scope): an admin grant of the same kit would collide with an event's, and a
+-- revert deleting "the" row would take the admin's grant with it. The push reads
+-- the UNION of the two, so a permission held both ways survives either being
+-- withdrawn.
+--
+-- The grant reaches every account the user has linked (D28), like any other.
+-- The CREDIT does not: `steam_id` is the account that took part, and one win is
+-- one extra use of the kit on that account (D103). `permission` is empty for a
+-- kit anybody may redeem — the credit is then the whole reward.
+--
+-- `idem_key` is core's idempotency key for the step. It is what a revert has
+-- when core lost the answer and holds no resource: without it the rows a lost
+-- answer wrote would be a grant nothing could ever withdraw.
+--
+-- `server_id` is the kit's server and the only one the grant reaches (D102).
+-- There is no foreign key to `rust_servers`: a server deleted mid-event must not
+-- delete the ledger a revert needs to find.
+CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ run_id VARCHAR(64) NOT NULL,
+ step_id VARCHAR(64) NOT NULL,
+ idem_key VARCHAR(190) NOT NULL DEFAULT '',
+ user_id INT NOT NULL,
+ server_id VARCHAR(64) NOT NULL,
+ steam_id VARCHAR(32) NOT NULL,
+ permission VARCHAR(128) NOT NULL DEFAULT '',
+ kit VARCHAR(128) NOT NULL,
+ credit TINYINT(1) NOT NULL DEFAULT 1,
+ granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_rust_perm_run_grant (run_id, step_id, user_id, permission),
+ KEY idx_rust_perm_run_grant_server (server_id),
+ KEY idx_rust_perm_run_grant_key (run_id, idem_key),
+ CONSTRAINT fk_rust_perm_run_grants_user
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- The news switch (D104): whether a published news post is also said in this
+-- server's chat. Off, because core enqueues every registered leg for every
+-- post, and without it the day this module updates every post would start
+-- appearing in every server's chat.
+ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS announce_news TINYINT(1) NOT NULL DEFAULT 0;
diff --git a/server/engagement/emit.js b/server/engagement/emit.js
index 9021220..07ae830 100644
--- a/server/engagement/emit.js
+++ b/server/engagement/emit.js
@@ -129,6 +129,11 @@ const HEADLINES = Object.freeze({
intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
+ 'If that was not you, unlink it from your Rust account page.',
}),
+ 'rust.kit.entitled': (d) => ({
+ title: `You earned ${d.kit} on ${d.server}`,
+ intro: `An event on ${d.server} rewarded you: the ${d.kit} kit is waiting in the Kits menu, `
+ + 'with one extra use. Redeem it in game.',
+ }),
'rust.clan.member.left': (d) => ({
title: `${d.member || 'A member'} left ${d.clan}`,
intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
@@ -546,12 +551,38 @@ function linked({ userId, steamId, name }) {
}
}
+/**
+ * `rust.kit.entitled` for each user one reward step granted (phase 13b). Never
+ * throws: a notification that could not be raised must not fail the grant it
+ * is about. Returns how many were raised.
+ */
+function entitled({ userIds, kit, server, mode, runId, stepId }) {
+ let raised = 0
+ try {
+ const rewardKey = `${runId}:${stepId}`
+ for (const userId of new Set(userIds || [])) {
+ const uid = Number(userId)
+ if (!Number.isInteger(uid) || uid < 1) continue
+ const ok = fire(T['rust.kit.entitled'], {
+ data: { rewardKey, kit: String(kit), ...serverVars(server), ...(mode ? { mode: String(mode) } : {}), accountUrl: PATHS.account },
+ ownerUserId: uid,
+ dedupeKey: dedupeKey('entitled', runId, stepId, uid),
+ })
+ if (ok) raised++
+ }
+ } catch (err) {
+ log.warn('could not raise the reward notification', { error: err.message })
+ }
+ return raised
+}
+
module.exports = {
onEvent,
serverObserved,
checkLeader,
sweepLoginDenied,
linked,
+ entitled,
reset,
dedupeKey,
headline,
diff --git a/server/engagement/seeds.js b/server/engagement/seeds.js
index 490b1c0..c1f84d1 100644
--- a/server/engagement/seeds.js
+++ b/server/engagement/seeds.js
@@ -32,7 +32,7 @@
// ── One rule group per family ─────────────────────────────────────────────
//
// A group is seeded ONCE (per deployment, per key), so a rule appended to a
-// group in a later version reaches fresh installs only. Seven families, seven
+// group in a later version reaches fresh installs only. Eight families, eight
// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
// — so installing this module mails nobody until an operator decides it should.
@@ -234,6 +234,25 @@ const RULE_GROUPS = Object.freeze([
},
],
},
+ {
+ // Its own group, not a rule appended to `account-v1`: a group is seeded once,
+ // so an appended rule would reach fresh installs only (R7).
+ key: 'rewards-v1',
+ note: 'module-rust: an event rewarded you a kit (disabled)',
+ rules: [
+ {
+ trigger_id: 'rust.kit.entitled',
+ name: 'Kit reward earned',
+ audience: 'owner',
+ // Email as well: a reward granted at 03:00 is news the person reads the
+ // next morning, before they are next in game or on the site.
+ channels: ['email', 'inapp'],
+ template_keys: GENERIC,
+ cooldown_seconds: 0,
+ max_sends_per_hour: 200,
+ },
+ ],
+ },
{
key: 'clans-v1',
note: 'module-rust: clan departures and disbands (disabled)',
diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js
index ea8485c..96320b0 100644
--- a/server/engagement/triggers.js
+++ b/server/engagement/triggers.js
@@ -218,6 +218,39 @@ const ACCOUNT = {
],
}
+// ── A reward (phase 13b) ───────────────────────────────────────────────────
+//
+// Deferred from phase 10 (D64) to the phase that grants something. Emitted once
+// per recipient USER when an event's `rust.kit.entitle` writes their rows, with
+// that user as `ownerUserId` — so, like the link notice, `owner` is both the
+// ceiling and the only audience there is: a reward is nobody else's news.
+//
+// The subject is the run and the step, so one award is one notification however
+// often a retried step writes the same rows.
+
+const REWARD = {
+ id: 'rust.kit.entitled',
+ label: 'An event rewarded you a kit',
+ description: 'An event on a Rust server rewarded you: a kit is waiting in the in-game Kits menu, with one extra use.',
+ kind: 'event',
+ subjectKey: 'rewardKey',
+ audience: 'owner',
+ ceiling: 'owner',
+ version: V1,
+ variables: [
+ ...HEADLINE,
+ { name: 'rewardKey', type: 'string', required: true, example: '41:7',
+ description: 'The run and step that awarded it. The cooldown subject; not meant for display.' },
+ { name: 'kit', type: 'string', required: true, example: 'vip-starter',
+ description: 'The kit name, as the server Kits plugin has it.' },
+ ...SERVER,
+ { name: 'mode', type: 'string', required: false, example: 'top',
+ description: 'How the recipients were chosen: everyone, top, minScore, random or topPercent.' },
+ { name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
+ description: 'Site-relative path to your Rust account page.' },
+ ],
+}
+
// ── Clans ──────────────────────────────────────────────────────────────────
//
// `members` ceiling — clan membership is the clan's business (D49). Recipients
@@ -374,7 +407,7 @@ const MODERATION = [
},
]
-const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION])
+const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
diff --git a/server/eventRewards.js b/server/eventRewards.js
new file mode 100644
index 0000000..8c0beb2
--- /dev/null
+++ b/server/eventRewards.js
@@ -0,0 +1,854 @@
+// ── What an event GIVES on a Rust server (PLAN.md §29, protocol 10) ───────
+//
+// 13a made things in the world. This file records who was there, gives them
+// something they can redeem, and can tell the server. Four verbs:
+//
+// rust.participation.open the game starts counting who takes part (D81)
+// rust.participation.collect core files the count as the run's participants
+// rust.kit.entitle the right to redeem a kit, and one more use of
+// it (R16, D103), for the people a mode picks
+// rust.announce one line in a server's chat, or every server's
+//
+// …and the announce leg, `rust.chat`, which says a published news post in the
+// chat of every server whose switch is on (D104).
+//
+// ── Who decides what ─────────────────────────────────────────────────────────
+//
+// The GAME counts: presence, kills, the score (D81, D99). The SITE picks the
+// recipients and holds the reward: the tally is read, a mode chosen per event
+// picks from it (D101), and a row per recipient goes into
+// `rust_perm_run_grants`, which the permission mirror pushes like any other
+// grant (D84). So a reward granted at 03:00 to somebody offline is waiting when
+// they next log in, and a wipe cannot take it away: the site re-pushes it.
+//
+// ── An action is never handed the participants ──────────────────────────────
+//
+// Core records participants from `collect`'s answer, but does not give them to
+// a later step. So `kit.entitle` reads the tally from the plugin itself, as
+// `uo.item.grant` reads it from the shard, and does not depend on a collect step
+// having run.
+
+const crypto = require('node:crypto')
+
+const core = require('./core')
+const client = require('./sidecarClient')
+const servers = require('./model/servers/servers.model')
+const permDb = require('./model/permissions/permissions.db')
+const linksDb = require('./model/links/links.db')
+const emit = require('./engagement/emit')
+const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
+const { BUDGET_MS } = require('./eventWorld')
+
+const log = core.logger('rewards')
+
+// Mirrors of the plugin's bounds (§29.5). The plugin's are authoritative, and
+// an operator may set them lower; these price a step and refuse a bad one on
+// the authoring form rather than at four in the morning.
+const MAX_RECIPIENTS = 100
+const MAX_CHAT = 256
+const TALLY_MAX_MINUTES = 7 * 24 * 60
+const MAX_KILL_WEIGHT = 1000
+const DEFAULT_KILL_WEIGHT = 5
+
+const SCORES = ['seconds', 'kills', 'both']
+const KILLS_OF = ['players', 'npcs', 'both']
+const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
+
+/** The fleet, in `rust.announce`'s `server` param (D105). */
+const EVERY_SERVER = '*'
+
+/** The plugin's refusals a second attempt would repeat. */
+const PERMANENT = new Set([
+ 'events-disabled',
+ 'malformed',
+ 'out-of-range',
+ 'already-open',
+ 'no-zone',
+ 'ambiguous-zone',
+ 'too-many',
+ 'too-long',
+ 'kits-missing',
+])
+
+const BUDGETS = [
+ {
+ id: 'rust.grants',
+ label: 'Kit rewards',
+ unit: 'rewards',
+ description:
+ 'Kits an event rewards: one per recipient, each the right to redeem the kit and one more use of it. A mode that is not a count is priced at the most it could grant.',
+ },
+ {
+ id: 'rust.announcements',
+ label: 'Chat announcements',
+ unit: 'lines',
+ description: 'Lines an event says in a server\'s chat: one per server reached.',
+ },
+]
+
+/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
+function transportFailure(server, result, what) {
+ const permanent = result.status === 'not-configured' || result.status === 'no-token'
+ return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
+}
+
+/** A plugin refusal carried in a 200, classified by its reason. */
+function refusal(server, data, what) {
+ return {
+ ok: false,
+ ...(PERMANENT.has(data && data.reason) ? { retry: false } : {}),
+ error: pluginError(data, `${server.name || server.id} refused the ${what}`),
+ }
+}
+
+/** One word from a fixed set, or null. Compared without case: an author types these. */
+function oneOf(raw, allowed) {
+ const text = String(raw === undefined || raw === null ? '' : raw).trim().toLowerCase()
+ return allowed.find((a) => a.toLowerCase() === text) || null
+}
+
+/** `:` for a tally, `::` for a reward (§29.3). */
+const tallyRef = (serverId, runId) => `${serverId}:${runId}`
+const entitlementRef = (serverId, runId, stepId) => `${serverId}:${runId}:${stepId}`
+
+/** A ref's parts, split at every colon — a server id has none and core's ids are numbers. */
+function refParts(ref) {
+ const [serverId, runId, stepId] = String(ref || '').split(':')
+ return { serverId: serverId || null, runId: runId || null, stepId: stepId || null }
+}
+
+// ── Picking recipients (D101) ────────────────────────────────────────────────
+
+/**
+ * The mode's `count`, checked. Returns `{ ok, value }` or a refusal sentence.
+ * `everyone` takes none.
+ */
+function checkCount(mode, raw) {
+ if (mode === 'everyone') return { ok: true, value: null }
+
+ const value = Number(raw)
+ if (mode === 'top' || mode === 'random') {
+ if (!Number.isInteger(value) || value < 1 || value > MAX_RECIPIENTS) {
+ return { ok: false, error: `${mode} names 1 to ${MAX_RECIPIENTS} people, and "${raw}" is not that` }
+ }
+ } else if (mode === 'topPercent') {
+ if (!Number.isFinite(value) || value <= 0 || value > 100) {
+ return { ok: false, error: `topPercent is a percentage above 0 and at most 100, not "${raw}"` }
+ }
+ } else if (!Number.isFinite(value) || value < 0) {
+ return { ok: false, error: `minScore is a score of 0 or more, not "${raw}"` }
+ }
+
+ return { ok: true, value }
+}
+
+/** Highest first; a tie keeps the order the game joined them in, so a list reads the same twice. */
+function ranked(people) {
+ return [...people].sort((a, b) => b.score - a.score || a.joinedAt - b.joinedAt || a.steamId.localeCompare(b.steamId))
+}
+
+/** The first `n` of a ranked list, and everybody tied with the last one in (D101). */
+function withTies(list, n) {
+ if (n <= 0 || !list.length) return []
+ if (n >= list.length) return list
+ const floor = list[n - 1].score
+ return list.filter((p, i) => i < n || p.score === floor)
+}
+
+/**
+ * Who a mode picks from a tally. Pure, and the whole of D101:
+ *
+ * everyone every participant who scored above zero
+ * top the N highest scores, ties in
+ * minScore a score of at least X
+ * random N drawn from everyone who took part, seeded by the step's key
+ * topPercent the highest X per cent, rounded up, ties in
+ *
+ * A score of zero earns nothing in the ranked modes: "the highest scores" of a
+ * tally where nobody scored is nobody. `random` draws from everyone present,
+ * which is the point of a raffle.
+ *
+ * **The draw is seeded by the idempotency key**, so a retry after a lost answer
+ * draws the same winners — each person's place is a hash of the key and their
+ * Steam id, which needs no generator state to reproduce.
+ */
+function pickRecipients(people, mode, count, seedKey) {
+ const scored = ranked(people.filter((p) => p.score > 0))
+
+ switch (mode) {
+ case 'everyone':
+ return scored
+ case 'top':
+ return withTies(scored, count)
+ case 'minScore':
+ return ranked(people.filter((p) => p.score >= count))
+ case 'topPercent':
+ return withTies(scored, Math.ceil((scored.length * count) / 100))
+ case 'random': {
+ const draw = (p) => crypto.createHash('sha256').update(`${seedKey}\u0000${p.steamId}`).digest('hex')
+ return [...people].sort((a, b) => draw(a).localeCompare(draw(b))).slice(0, count)
+ }
+ default:
+ return []
+ }
+}
+
+/** The tally's rows as numbers, whatever the wire carried. */
+function peopleOf(data) {
+ return ((data && data.people) || [])
+ .filter((p) => p && p.steamId)
+ .map((p) => ({
+ steamId: String(p.steamId),
+ name: p.name ? String(p.name) : String(p.steamId),
+ seconds: Number(p.seconds) || 0,
+ kills: Number(p.kills) || 0,
+ score: Number(p.score) || 0,
+ joinedAt: Number(p.joinedAt) || 0,
+ }))
+}
+
+/** Steam id -> website user, for the ids that are linked. */
+async function usersFor(steamIds) {
+ const ids = [...new Set(steamIds.map(String))]
+ if (!ids.length) return new Map()
+ const rows = await linksDb.userIdsForSteamIds(ids)
+ return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)]))
+}
+
+/** The tally for a run on one server, or a classified failure. */
+async function readTally(server, runId) {
+ const result = await client.tallySnapshot(server, runId)
+ if (!result.ok) return transportFailure(server, result, 'tally')
+ const data = result.data || {}
+ if (data.kind !== 'tally.snapshot') {
+ // `no-tally` is permanent for THIS step: the tally it reads was never opened
+ // on this server, or teardown already closed it.
+ if (data.reason === 'no-tally') {
+ return {
+ ok: false,
+ retry: false,
+ error: `${server.name || server.id} holds no tally for this run — open one with rust.participation.open on the same server first`,
+ }
+ }
+ return refusal(server, data, 'tally')
+ }
+ return { ok: true, data }
+}
+
+// ── The kit, as its server's Kits plugin describes it ───────────────────────
+
+/**
+ * `/` split at the FIRST slash: a server id never contains one,
+ * and a kit name is whatever an operator typed into Kits.
+ */
+function splitKit(value) {
+ const text = String(value || '').trim()
+ const slash = text.indexOf('/')
+ if (slash <= 0 || slash === text.length - 1) return null
+ return { serverId: text.slice(0, slash), kit: text.slice(slash + 1) }
+}
+
+/** What a kit rewards, as the source's label says it and the verb checks it (R16, D103). */
+function kitReward(row) {
+ const permission = String(row.permission || '').trim().toLowerCase()
+ const max = Number(row.max) || 0
+ return { permission, max, rewardsNothing: !permission && max <= 0 }
+}
+
+async function readKit(server, kit) {
+ const result = await client.kits(server)
+ if (!result.ok) return transportFailure(server, result, 'kits')
+ const data = result.data || {}
+ if (data.kind !== 'kits.list') return refusal(server, data, 'kit list')
+
+ const row = (data.kits || []).find((k) => k && String(k.name).toLowerCase() === kit.toLowerCase())
+ if (!row) return { ok: false, retry: false, error: `${server.name || server.id} has no kit called "${kit}"` }
+
+ const reward = kitReward(row)
+ if (reward.rewardsNothing) {
+ return {
+ ok: false,
+ retry: false,
+ error: `the kit "${row.name}" is open to everyone and has no use limit, so a reward of it gives nobody anything — give it a permission or a maximum number of uses in Kits`,
+ }
+ }
+
+ return { ok: true, kit: String(row.name), ...reward, maxRecipients: Number(data.maxRecipients) || MAX_RECIPIENTS }
+}
+
+// ── The verbs ────────────────────────────────────────────────────────────────
+
+const participationOpen = {
+ id: 'rust.participation.open',
+ label: 'Start counting participants',
+ description:
+ 'The game counts who takes part from here on: time present, kills, or both — in a zone this run opened, or on the whole server. It stops after its minutes; teardown forgets it.',
+ // It watches rather than changes anything, but it is ledgered, like
+ // `uo.participation.open`: the game holds a tally for the run, and teardown
+ // gives it back.
+ risk: 'inspect',
+ reversible: 'ledger',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
+ description: 'Which server counts.' },
+ { name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runZones',
+ description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
+ { name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoreModes',
+ description: 'What earns a place: seconds present, kills, or both.' },
+ { name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsOf',
+ description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' },
+ { name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT,
+ description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` },
+ { name: 'minutes', type: 'int', required: false, example: 60,
+ description: `How long it counts, up to ${TALLY_MAX_MINUTES} (seven days). Left blank, seven days. The game forgets a tally seven days after it opened, however long it counted.` },
+ ],
+ cost: () => ({}),
+
+ async perform({ runId, idempotencyKey, params, verify }) {
+ const score = oneOf(params.score, SCORES)
+ if (!score) return { ok: false, retry: false, error: `a tally scores seconds, kills or both, not "${params.score}"` }
+
+ const killsOf = score === 'seconds' ? null : oneOf(params.killsOf, KILLS_OF)
+ if (score !== 'seconds' && !killsOf) {
+ return { ok: false, retry: false, error: 'a tally that counts kills says whose: players, npcs or both' }
+ }
+
+ let killWeight
+ if (score === 'both') {
+ const raw = params.killWeight
+ killWeight = raw === undefined || raw === null || raw === '' ? DEFAULT_KILL_WEIGHT : Number(raw)
+ if (!Number.isFinite(killWeight) || killWeight < 0 || killWeight > MAX_KILL_WEIGHT) {
+ return { ok: false, retry: false, error: `a kill is worth 0 to ${MAX_KILL_WEIGHT} minutes, not "${raw}"` }
+ }
+ }
+
+ let minutes
+ if (params.minutes !== undefined && params.minutes !== null && params.minutes !== '') {
+ minutes = Number(params.minutes)
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > TALLY_MAX_MINUTES) {
+ return { ok: false, retry: false, error: `a tally counts for 1 to ${TALLY_MAX_MINUTES} minutes, not "${params.minutes}"` }
+ }
+ }
+
+ const zone = String(params.zone || '').trim()
+ const found = await serverFor(String(params.server || '').trim())
+ if (!found.ok) return found
+
+ // Whether the zone exists is not asked in a dry run: it is opened by an
+ // earlier step of the same run, so before the run it never does.
+ if (verify) return { ok: true }
+
+ const result = await client.tallyOpen(found.server, {
+ runId: String(runId),
+ key: idempotencyKey,
+ score,
+ ...(killsOf ? { killsOf } : {}),
+ ...(killWeight === undefined ? {} : { killWeight }),
+ ...(minutes === undefined ? {} : { holdMs: minutes * 60000 }),
+ ...(zone ? { zone } : {}),
+ })
+ if (!result.ok) return transportFailure(found.server, result, 'tally')
+ const data = result.data || {}
+ if (data.kind !== 'tally.ok') return refusal(found.server, data, 'tally')
+
+ return {
+ ok: true,
+ resources: [
+ {
+ kind: 'tally',
+ ref: tallyRef(found.server.id, runId),
+ payload: { serverId: found.server.id, score, ...(zone ? { zone } : {}) },
+ },
+ ],
+ detail: {
+ server: found.server.name || found.server.id,
+ counting: zone ? `in the zone "${zone}"` : 'on the whole server',
+ ...(data.repeat ? { repeat: true, note: 'answered from the first attempt; the tally was already open' } : {}),
+ },
+ }
+ },
+
+ /**
+ * Forget the tally on every server the ledger names — or, when core lost the
+ * answer and holds none, on every server, since `runId` is all a tally is
+ * keyed by. A tally already gone is a success.
+ */
+ async revert({ runId, resources }) {
+ const targets = resources && resources.length
+ ? [...new Set(resources.map((r) => (r.payload && r.payload.serverId) || refParts(r.ref).serverId))]
+ : (await servers.listForPolling()).map((s) => s.id)
+
+ const failed = []
+ const errors = []
+ for (const serverId of targets) {
+ const found = await serverFor(serverId)
+ if (!found.ok) {
+ // A server deleted or switched off cannot be asked, and its tally ends on
+ // its own seven days after it opened; the ledger row is not held for it.
+ continue
+ }
+ const result = await client.tallyClose(found.server, { runId: String(runId) })
+ const refused = result.ok && (!result.data || result.data.kind !== 'tally.ok')
+ if (!result.ok || refused) {
+ failed.push(...(resources || []).filter((r) => refParts(r.ref).serverId === serverId).map((r) => r.ref))
+ errors.push(result.ok ? pluginError(result.data, `${found.server.name || found.server.id} refused to close the tally`) : transportError(found.server, result, 'tally'))
+ }
+ }
+
+ if (!errors.length) return { ok: true }
+ if (!resources || !resources.length || failed.length === resources.length) return { ok: false, error: errors.join('; ') }
+ return { ok: true, failed }
+ },
+
+ /** A tally is in force while its server still holds it. A server that cannot be asked has said nothing. */
+ async reconcile({ runId, resources }) {
+ const inForce = []
+ for (const r of resources || []) {
+ const found = await serverFor(refParts(r.ref).serverId)
+ if (!found.ok) {
+ inForce.push(r.ref)
+ continue
+ }
+ const result = await client.tallySnapshot(found.server, runId)
+ const gone = result.ok && result.data && result.data.kind !== 'tally.snapshot' && result.data.reason === 'no-tally'
+ if (!gone) inForce.push(r.ref)
+ }
+ return { ok: true, inForce }
+ },
+}
+
+const participationCollect = {
+ id: 'rust.participation.collect',
+ label: 'Record participants',
+ description:
+ 'Files everybody the tally counted as this run\'s participants, with their score, time and kills. The tally keeps counting if its minutes are not up.',
+ risk: 'inspect',
+ reversible: 'none',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
+ description: 'The server whose tally to read.' },
+ ],
+ cost: () => ({}),
+
+ async perform({ runId, params, verify }) {
+ const found = await serverFor(String(params.server || '').trim())
+ if (!found.ok) return found
+ if (verify) return { ok: true }
+
+ const tally = await readTally(found.server, runId)
+ if (!tally.ok) return tally
+
+ const people = peopleOf(tally.data)
+ const users = await usersFor(people.map((p) => p.steamId))
+
+ return {
+ ok: true,
+ // The member vocabulary is the Steam id, as the team provider's is.
+ participants: people.map((p) => ({
+ memberKey: p.steamId,
+ ...(users.has(p.steamId) ? { userId: users.get(p.steamId) } : {}),
+ score: p.score,
+ ...(p.joinedAt > 0 ? { joinedAt: new Date(p.joinedAt).toISOString() } : {}),
+ meta: { name: p.name, seconds: p.seconds, kills: p.kills },
+ })),
+ detail: {
+ server: found.server.name || found.server.id,
+ participants: people.length,
+ linked: users.size,
+ ...(Number(tally.data.overflow) > 0 ? { overflow: Number(tally.data.overflow) } : {}),
+ },
+ }
+ },
+}
+
+const kitEntitle = {
+ id: 'rust.kit.entitle',
+ label: 'Reward a kit',
+ description:
+ 'Gives the people a mode picks from this run\'s tally the right to redeem a kit on its server, and one more use of it. Waits for them if they are offline. Teardown withdraws what is not yet redeemed.',
+ risk: 'change',
+ reversible: 'ledger',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
+ description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
+ { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientModes',
+ description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' },
+ { name: 'count', type: 'float', required: false, example: 3,
+ description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' },
+ ],
+
+ // Priced before the tally is read, so at the most it could grant: the count
+ // for a count, and the server's recipient bound for every other mode. An
+ // author who wants a tight cap picks a count.
+ cost: (p) => {
+ const mode = oneOf(p.recipients, MODES)
+ const n = Math.round(Number(p.count) || 0)
+ return { 'rust.grants': mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS }
+ },
+
+ async perform({ runId, stepId, idempotencyKey, params, verify }) {
+ const parsed = splitKit(params.kit)
+ if (!parsed) return { ok: false, retry: false, error: `"${params.kit}" is not a kit — pick one from the list, as server/kit` }
+
+ const mode = oneOf(params.recipients, MODES)
+ if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
+
+ const count = checkCount(mode, params.count)
+ if (!count.ok) return { ok: false, retry: false, error: count.error }
+
+ const found = await serverFor(parsed.serverId)
+ if (!found.ok) return found
+ const server = found.server
+
+ const kit = await readKit(server, parsed.kit)
+ if (!kit.ok) return kit
+
+ if ((mode === 'top' || mode === 'random') && count.value > kit.maxRecipients) {
+ return { ok: false, retry: false, error: `${server.name || server.id} rewards at most ${kit.maxRecipients} people in one step, not ${count.value}` }
+ }
+
+ if (verify) return { ok: true }
+
+ const ref = entitlementRef(server.id, runId, stepId)
+ const resource = { kind: 'entitlement', ref, payload: { serverId: server.id, kit: kit.kit } }
+
+ // A repeated key finds its rows already written and changes nothing — the
+ // rows ARE the grant, and a set written twice is the same set.
+ const existing = await permDb.listRunGrantsForStep(runId, stepId)
+ if (existing.length) {
+ return {
+ ok: true,
+ resources: [resource],
+ detail: { repeat: true, granted: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was granted' },
+ }
+ }
+
+ const tally = await readTally(server, runId)
+ if (!tally.ok) return tally
+
+ const picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
+ const bound = Number(tally.data.maxRecipients) || kit.maxRecipients
+ if (picked.length > bound) {
+ return {
+ ok: false,
+ retry: false,
+ error: `${picked.length} people qualify, and ${server.name || server.id} rewards at most ${bound} in one step — pick a count-based mode or a higher bar`,
+ }
+ }
+
+ const users = await usersFor(picked.map((p) => p.steamId))
+ const rows = []
+ const byUser = new Set()
+ const missed = []
+
+ for (const p of picked) {
+ const userId = users.get(p.steamId)
+ if (!userId) {
+ missed.push(p.name)
+ continue
+ }
+ // One reward per website user: two linked accounts that both took part are
+ // one person, and one win is one use (D103). The higher score, being
+ // earlier in the list, is the account that gets the credit.
+ if (byUser.has(userId)) continue
+ byUser.add(userId)
+ rows.push({
+ runId,
+ stepId,
+ idemKey: idempotencyKey,
+ userId,
+ serverId: server.id,
+ steamId: p.steamId,
+ permission: kit.permission,
+ kit: kit.kit,
+ credit: kit.max > 0,
+ })
+ }
+
+ await permDb.insertRunGrants(rows)
+ await permDb.markDirty(server.id)
+
+ emit.entitled({ userIds: [...byUser], kit: kit.kit, server, mode, runId, stepId })
+
+ log.info('kit rewarded', { server: server.id, run: runId, step: stepId, kit: kit.kit, mode, granted: rows.length, missed: missed.length })
+
+ return {
+ ok: true,
+ resources: [resource],
+ detail: {
+ kit: kit.kit,
+ server: server.name || server.id,
+ mode,
+ ...(count.value === null ? {} : { count: count.value }),
+ granted: rows.length,
+ ...(missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}),
+ },
+ }
+ },
+
+ /**
+ * Withdraw a step's rows and push. The permission goes and each unredeemed
+ * credit is put back by the plugin; a redemption already made stands (R16).
+ * A row already gone is a success.
+ */
+ async revert({ runId, resources, idempotencyKey }) {
+ const touched = new Set()
+
+ if (!resources || !resources.length) {
+ for (const serverId of await permDb.deleteRunGrantsForKey(runId, idempotencyKey)) touched.add(serverId)
+ } else {
+ for (const r of resources) {
+ const { runId: refRun, stepId } = refParts(r.ref)
+ if (!stepId) continue
+ for (const serverId of await permDb.deleteRunGrantsForStep(refRun || runId, stepId)) touched.add(serverId)
+ }
+ }
+
+ for (const serverId of touched) await permDb.markDirty(serverId)
+ return { ok: true }
+ },
+
+ /** The site holds the entitlement and re-pushes it, so a restart or a wipe cannot take it away. */
+ async reconcile({ resources }) {
+ return { ok: true, inForce: (resources || []).map((r) => r.ref) }
+ },
+}
+
+/**
+ * Say one line on one server, and classify the answer. `repeat` is a success:
+ * the plugin remembered the key, and the line was already said.
+ */
+async function sayOn(server, body) {
+ const result = await client.chat(server, body)
+ if (!result.ok) return { state: 'down', result }
+ const data = result.data || {}
+ if (data.kind !== 'chat.ok') return { state: 'refused', data }
+ return { state: data.said === false ? 'repeat' : 'said', data }
+}
+
+const announce = {
+ id: 'rust.announce',
+ label: 'Say it in game chat',
+ description: 'One line in a Rust server\'s chat, or in every server\'s. A line said cannot be taken back.',
+ risk: 'notify',
+ reversible: 'none',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: [
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatServers',
+ description: 'Which server, or * for every server (D105).' },
+ { name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
+ description: `The line, up to ${MAX_CHAT} characters.` },
+ ],
+
+ // One per server reached. `*` is priced at the enabled servers when core asks,
+ // which is synchronous — so at the count this module last saw.
+ cost: (p) => ({ 'rust.announcements': String(p.server || '').trim() === EVERY_SERVER ? Math.max(1, servers.lastEnabledCount()) : 1 }),
+
+ async perform({ runId, idempotencyKey, params, verify }) {
+ const message = String(params.message || '').replace(/\s+/g, ' ').trim()
+ if (!message) return { ok: false, retry: false, error: 'a chat line needs a message' }
+ if (message.length > MAX_CHAT) {
+ return { ok: false, retry: false, error: `a chat line is at most ${MAX_CHAT} characters, and this one is ${message.length}` }
+ }
+
+ const target = String(params.server || '').trim()
+ let list
+ if (target === EVERY_SERVER) {
+ list = await servers.listForPolling()
+ if (!list.length) return { ok: false, retry: false, error: 'there are no enabled Rust servers to say it on' }
+ } else {
+ const found = await serverFor(target)
+ if (!found.ok) return found
+ list = [found.server]
+ }
+
+ if (verify) return { ok: true }
+
+ const body = { key: idempotencyKey || `run:${runId}`, message, event: true }
+ const outcomes = await Promise.all(list.map(async (server) => ({ server, ...(await sayOn(server, body)) })))
+ const name = (o) => o.server.name || o.server.id
+
+ // One server named: its answer is the step's.
+ if (target !== EVERY_SERVER) {
+ const o = outcomes[0]
+ if (o.state === 'down') return transportFailure(o.server, o.result, 'chat')
+ if (o.state === 'refused') return refusal(o.server, o.data, 'chat line')
+ return { ok: true, detail: { said: [name(o)], ...(o.state === 'repeat' ? { repeat: true } : {}) } }
+ }
+
+ // Every server: a success for each that took the line, and the rest named
+ // (D104's reason — a line said an hour late in a restarted server is noise).
+ const said = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat').map(name)
+ const down = outcomes.filter((o) => o.state === 'down').map(name)
+ const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${name(o)}: ${pluginError(o.data, 'refused')}`)
+
+ if (!said.length && refused.length) return { ok: false, retry: false, error: refused.join('; ') }
+ if (!said.length) return { ok: false, error: `no server could be reached: ${down.join(', ')}` }
+
+ return { ok: true, detail: { said, ...(down.length ? { down } : {}), ...(refused.length ? { refused } : {}) } }
+ },
+}
+
+// ── The announce leg (D104) ──────────────────────────────────────────────────
+
+/** A news post as one chat line: its title, or failing that its excerpt, flattened and bounded. */
+function chatLine(post) {
+ const text = String((post && (post.title || post.excerpt)) || '').replace(/\s+/g, ' ').trim()
+ return text.length > MAX_CHAT ? `${text.slice(0, MAX_CHAT - 1)}…` : text
+}
+
+/**
+ * The plugin's memory of recent keys, keyed off the post: its id when core's
+ * news path gives one, else what it says — `core.announce` hands a leg a post
+ * with no id. Either way a retried leg never says the same line twice.
+ */
+function chatKey(post, line) {
+ if (post && post.id !== undefined && post.id !== null) return `news:${post.id}`
+ return `news:${crypto.createHash('sha1').update(line).digest('hex')}`
+}
+
+const LEG = {
+ leg: 'rust.chat',
+ label: 'Rust in-game chat',
+
+ /**
+ * Say a post in the chat of every server whose switch is on. Never throws, as
+ * every leg client must not. The answer is one outcome per switched-on
+ * server, for `classify`.
+ */
+ async dispatch(post) {
+ try {
+ const line = chatLine(post)
+ if (!line) return { ok: false, empty: true, outcomes: [] }
+
+ const list = (await servers.listForPolling()).filter((s) => s.announceNews)
+ const key = chatKey(post, line)
+ const outcomes = await Promise.all(
+ list.map(async (server) => ({ server: server.name || server.id, ...(await sayOn(server, { key, message: line })) })),
+ )
+ return { ok: true, outcomes }
+ } catch (err) {
+ log.warn('news chat leg failed', { error: err.message })
+ return { ok: false, error: err.message, outcomes: [] }
+ }
+ },
+
+ /**
+ * `done` when every switched-on server that is up took the line — or when no
+ * server is switched on, since there is nothing to deliver. `retry` only when
+ * every switched-on server is down. A server that refused is named; one that
+ * was down is skipped, never queued (D104).
+ */
+ classify(result) {
+ if (!result || (!result.ok && !result.empty && !result.outcomes)) return { outcome: 'retry', error: (result && result.error) || 'no answer' }
+ if (result.empty) return { outcome: 'terminal', error: 'the post has no title or excerpt to say' }
+ if (!result.ok) return { outcome: 'retry', error: result.error || 'the leg failed' }
+
+ const outcomes = result.outcomes || []
+ if (!outcomes.length) return { outcome: 'done' }
+
+ const took = outcomes.filter((o) => o.state === 'said' || o.state === 'repeat')
+ const down = outcomes.filter((o) => o.state === 'down').map((o) => o.server)
+ const refused = outcomes.filter((o) => o.state === 'refused').map((o) => `${o.server}: ${pluginError(o.data, 'refused')}`)
+
+ if (down.length === outcomes.length) return { outcome: 'retry', error: `every server is down: ${down.join(', ')}` }
+ if (!took.length) return { outcome: 'terminal', error: refused.join('; ') }
+
+ const notes = [...(down.length ? [`skipped (down): ${down.join(', ')}`] : []), ...refused]
+ return notes.length ? { outcome: 'done', error: notes.join('; ') } : { outcome: 'done' }
+ },
+}
+
+// ── Option sources ───────────────────────────────────────────────────────────
+
+/** A fixed choice as a dropdown — core has no enum type, so a source is how a field offers words. */
+const fixed = (id, label, description, rows) => ({ id, label, description, async resolve() { return rows } })
+
+const OPTION_SOURCES = [
+ {
+ // Live from each server's Kits, so the form offers only kits that exist. The
+ // label says what a reward of each one gives (R16, D103).
+ id: 'rust.options.kits',
+ label: 'Kits',
+ description: "Each server's Kits, flagged by what a reward of one gives.",
+ searchable: true,
+ async resolve({ q } = {}) {
+ const term = String(q || '').trim().toLowerCase()
+ const answers = await perServer((server) => client.kits(server))
+ const rows = []
+ for (const { server, result } of answers) {
+ const data = result.data || {}
+ if (data.kind !== 'kits.list') continue
+ for (const k of data.kits || []) {
+ if (!k || !k.name) continue
+ const value = `${server.id}/${k.name}`
+ if (term && !value.toLowerCase().includes(term)) continue
+ const reward = kitReward(k)
+ const flags = [
+ reward.permission ? null : 'open to everyone',
+ reward.max > 0 ? `${reward.max} use${reward.max === 1 ? '' : 's'}` : null,
+ reward.rewardsNothing ? 'rewards nothing' : null,
+ ].filter(Boolean)
+ rows.push({ value, label: flags.length ? `${k.name} · ${flags.join(' · ')}` : String(k.name), group: server.name || server.id })
+ }
+ }
+ return bounded(rows, 'rust.options.kits')
+ },
+ },
+ // Free text: the zones a run will open do not exist when it is authored, and
+ // the name is checked when the step runs (D100). Declared so the field is
+ // documented rather than a bare box, and answers nothing.
+ fixed('rust.options.runZones', 'Zones this run opens', 'The name an earlier "Open a zone" step of the same run gave its zone. Type it; it is checked when the step runs.', []),
+ fixed('rust.options.scoreModes', 'Score', 'What earns a place in a tally.', [
+ { value: 'seconds', label: 'Seconds present' },
+ { value: 'kills', label: 'Kills' },
+ { value: 'both', label: 'Both — minutes plus a weight per kill' },
+ ]),
+ fixed('rust.options.killsOf', 'Kills of', 'Whose deaths count as a kill.', [
+ { value: 'players', label: 'Players' },
+ { value: 'npcs', label: 'NPCs, animals included' },
+ { value: 'both', label: 'Players and NPCs' },
+ ]),
+ fixed('rust.options.recipientModes', 'Recipients', 'Who a reward goes to (D101).', [
+ { value: 'everyone', label: 'Everyone who scored' },
+ { value: 'top', label: 'The top N (ties in)' },
+ { value: 'minScore', label: 'A score of at least X' },
+ { value: 'random', label: 'N drawn at random' },
+ { value: 'topPercent', label: 'The top X per cent (ties in)' },
+ ]),
+ {
+ id: 'rust.options.chatServers',
+ label: 'Chat servers',
+ description: 'Every enabled server, or * for all of them.',
+ async resolve() {
+ const list = await servers.listForPolling()
+ return [{ value: EVERY_SERVER, label: 'Every server' }, ...list.map((s) => ({ value: s.id, label: s.name || s.id }))]
+ },
+ },
+]
+
+const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
+
+module.exports = {
+ MAX_RECIPIENTS,
+ MAX_CHAT,
+ EVERY_SERVER,
+ BUDGETS,
+ ACTIONS,
+ LEG,
+ OPTION_SOURCES,
+ pickRecipients,
+ checkCount,
+ splitKit,
+ kitReward,
+ chatLine,
+ chatKey,
+ refParts,
+}
diff --git a/server/index.js b/server/index.js
index 959459d..238e882 100644
--- a/server/index.js
+++ b/server/index.js
@@ -58,6 +58,7 @@ module.exports = function register(ctx, api) {
const seeds = require('./engagement/seeds')
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
+ const eventRewards = require('./eventRewards')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -129,8 +130,9 @@ module.exports = function register(ctx, api) {
// refresh. Registration is a claim, not a call: nothing here touches the
// database, and the seeds are written by core after the schema is up.
//
- // **Not registered, and that is D62:** no announce leg and no post hook. Both
- // need something in game to deliver to, and phase 10 reaches no game.
+ // The announce leg arrived with phase 13b (D62, D104), when there was a chat
+ // verb to deliver through; it is registered below with the rewards. There is
+ // still no post hook: nothing in game mirrors a post as state.
api.registerEventTriggers(TRIGGERS)
api.registerNotificationStreams(STREAMS)
api.registerAudiences(AUDIENCES)
@@ -160,19 +162,32 @@ module.exports = function register(ctx, api) {
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
// back — a zone, crates, NPCs — and the budgets that price them, each declared
// beside the verb that spends it (D79, D89). A lease spends none of them.
- api.registerEventBudgets(eventWorld.BUDGETS)
- api.registerEventActions(eventWorld.ACTIONS)
+ //
+ // The rewards (PLAN.md §29, protocol 10) join them: the tally, the kit reward
+ // and the chat line, with the two budgets they spend. Registered in the same
+ // calls' neighbours, not merged into eventWorld's arrays, so each file keeps
+ // its own statement of what it declares.
+ api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
+ api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
+
+ // News in game chat (D104). Core enqueues every registered leg for every
+ // published post, so the leg itself sends only to the servers whose switch an
+ // operator turned on — off by default, and a server that is down is skipped.
+ api.registerAnnounceLeg(eventRewards.LEG)
// ONE call for every option source: core takes a batch once, as this module's
// complete statement, and refuses a second.
- api.registerEventOptionSources([...eventLeases.OPTION_SOURCES, ...eventWorld.OPTION_SOURCES])
+ api.registerEventOptionSources([
+ ...eventLeases.OPTION_SOURCES,
+ ...eventWorld.OPTION_SOURCES,
+ ...eventRewards.OPTION_SOURCES,
+ ])
- // Everything else this module will register — the rewards and the announce
- // leg (13b), 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.
+ // Everything else this module will register — the slash commands — is
+ // deliberately absent, and 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,
@@ -183,8 +198,9 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
- actions: eventWorld.ACTIONS.length,
- budgets: eventWorld.BUDGETS.length,
- optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length,
+ actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
+ budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
+ announceLeg: eventRewards.LEG.leg,
+ optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length,
})
}
diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js
index d33ca48..9eab355 100644
--- a/server/model/permissions/permissions.db.js
+++ b/server/model/permissions/permissions.db.js
@@ -28,6 +28,7 @@ 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 RUN_GRANTS = 'rust_perm_run_grants'
const PUSHED = 'rust_perm_pushed'
const DRIFT = 'rust_perm_drift'
const REVOCATIONS = 'rust_perm_revocations'
@@ -190,6 +191,76 @@ async function deleteGrant(id) {
return Number(result.affectedRows || 0) > 0
}
+// ---- what events granted (phase 13b) ----
+//
+// `rust_perm_run_grants` is authored by `rust.kit.entitle`, never by a person,
+// and it is read beside `rust_perm_grants` rather than merged into it (D84): the
+// push unions the two, and a revert deletes exactly one step's rows.
+
+/** Every event grant, for the push. Small: one row per recipient per reward step still standing. */
+async function listRunGrants() {
+ return core.query(
+ `SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
+ steam_id AS steamId, permission, kit, credit
+ FROM ${RUN_GRANTS}`,
+ )
+}
+
+/** One step's rows. A repeated key finds them here and writes nothing new. */
+async function listRunGrantsForStep(runId, stepId) {
+ return core.query(
+ `SELECT user_id AS userId, server_id AS serverId, steam_id AS steamId, permission, kit, credit
+ FROM ${RUN_GRANTS}
+ WHERE run_id = ? AND step_id = ?`,
+ [String(runId), String(stepId)],
+ )
+}
+
+/**
+ * One step's recipients, in one statement. `INSERT IGNORE` against the unique
+ * key, so a retry that races the first attempt writes each row once.
+ */
+async function insertRunGrants(rows) {
+ if (!rows.length) return 0
+
+ const result = await core.query(
+ `INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
+ VALUES ${placeholders(rows, 9)}`,
+ rows.flatMap((r) => [
+ String(r.runId),
+ String(r.stepId),
+ String(r.idemKey || ''),
+ r.userId,
+ r.serverId,
+ r.steamId,
+ r.permission || '',
+ r.kit,
+ r.credit ? 1 : 0,
+ ]),
+ )
+
+ return Number(result.affectedRows || 0)
+}
+
+/** Withdraw one step's rows. Returns the servers they were on; none is a success. */
+async function deleteRunGrantsForStep(runId, stepId) {
+ return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
+}
+
+/** The same, found by core's idempotency key — the revert of an answer core lost. */
+async function deleteRunGrantsForKey(runId, idemKey) {
+ if (!idemKey) return []
+ return deleteRunGrantsWhere('run_id = ? AND idem_key = ?', [String(runId), String(idemKey)])
+}
+
+async function deleteRunGrantsWhere(where, params) {
+ const found = await core.query(`SELECT DISTINCT server_id AS serverId FROM ${RUN_GRANTS} WHERE ${where}`, params)
+ if (!found.length) return []
+
+ await core.query(`DELETE FROM ${RUN_GRANTS} WHERE ${where}`, params)
+ return found.map((row) => row.serverId)
+}
+
/**
* One website account by name, for the authoring form.
*
@@ -463,6 +534,7 @@ async function listCatalogue() {
module.exports = {
GROUPS,
GRANTS,
+ RUN_GRANTS,
PUSHED,
DRIFT,
listGroups,
@@ -478,6 +550,11 @@ module.exports = {
getGrant,
insertGrant,
deleteGrant,
+ listRunGrants,
+ listRunGrantsForStep,
+ insertRunGrants,
+ deleteRunGrantsForStep,
+ deleteRunGrantsForKey,
findUserByUsername,
listLinks,
listGroupsForUser,
diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js
index db85b18..0ef145e 100644
--- a/server/model/permissions/permissions.model.js
+++ b/server/model/permissions/permissions.model.js
@@ -278,12 +278,13 @@ async function forPlayer(userId, steamIds, serverRows) {
* server is six times the queries for the same rows.
*/
async function readAuthored() {
- const [groups, groupPermissions, members, grants, links] = await Promise.all([
+ const [groups, groupPermissions, members, grants, links, runGrants] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listLinks(),
+ db.listRunGrants(),
])
const steamIdsByUser = new Map()
@@ -293,7 +294,7 @@ async function readAuthored() {
steamIdsByUser.get(link.userId).push(link.steamId)
}
- return { groups, groupPermissions, members, grants, steamIdsByUser }
+ return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser }
}
/**
@@ -313,6 +314,7 @@ async function readAuthored() {
*/
function buildDesired(serverId, authored) {
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
+ const runGrants = authored.runGrants || []
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
const groupNames = new Set(scopedGroups.map((group) => group.name))
@@ -378,6 +380,54 @@ function buildDesired(serverId, authored) {
}
}
+ // ── What events granted (phase 13b, D84) ──────────────────────────────
+ //
+ // Unioned with the admin grants above through the same `seenGrant`, so a
+ // permission held both ways is ONE row in the game — and withdrawing either
+ // leaves the other standing, because the next build still finds it.
+ //
+ // An event grant reaches only the kit's server (D102), and like any grant it
+ // reaches every account the user has linked (D28).
+ //
+ // The CREDIT is different: one win is one extra use, on the account that took
+ // part, and only while that account is still linked to the user who won it.
+ const credits = new Map()
+
+ for (const row of runGrants) {
+ if (row.serverId !== serverId) continue
+
+ const linked = steamIdsByUser.get(row.userId) || []
+ const permission = normaliseName(row.permission)
+
+ if (permission) {
+ managed.add(permission)
+
+ for (const steamId of linked) {
+ 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 })
+ }
+ }
+
+ if (Number(row.credit) && linked.includes(row.steamId)) {
+ // A Steam id is digits, so the first bar is always the split; a kit name
+ // may contain one.
+ const key = `${row.steamId}|${row.kit}`
+ credits.set(key, (credits.get(key) || 0) + 1)
+ }
+ }
+
+ const creditRows = [...credits.entries()]
+ .map(([key, count]) => {
+ const bar = key.indexOf('|')
+ return { steamId: key.slice(0, bar), kit: key.slice(bar + 1), count }
+ })
+ .sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
+
const payload = {
groups: scopedGroups.map((group) => ({
name: group.name,
@@ -391,9 +441,21 @@ function buildDesired(serverId, authored) {
permissions,
})),
managed: [...managed].sort(),
+ // Always sent, even empty: to the plugin an absent field means "this site
+ // says nothing about credits", and an empty one means "nobody has any" —
+ // which is what a revert of the last reward must be able to say (D103).
+ credits: creditRows,
}
- return { payload, rows, hash: hashRows(rows) }
+ // Credits are in the digest, so a new reward or a revert pushes, but they are
+ // NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
+ // something in the permission store to retire.
+ const hashed = [
+ ...rows,
+ ...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
+ ]
+
+ return { payload, rows, hash: hashRows(hashed) }
}
/**
diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js
index 0c4e242..ca61734 100644
--- a/server/model/servers/servers.db.js
+++ b/server/model/servers/servers.db.js
@@ -23,7 +23,8 @@ const STATE = 'rust_server_state'
async function listServers({ enabledOnly = false } = {}) {
return core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
- protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
+ protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
+ created_at AS createdAt, updated_at AS updatedAt
FROM ${SERVERS}
${enabledOnly ? 'WHERE enabled = 1' : ''}
ORDER BY sort_order ASC, id ASC`,
diff --git a/server/model/servers/servers.model.js b/server/model/servers/servers.model.js
index 40736b8..5377360 100644
--- a/server/model/servers/servers.model.js
+++ b/server/model/servers/servers.model.js
@@ -48,12 +48,33 @@ function withToken(row) {
}
}
- return { id: row.id, name: row.name, baseUrl: row.sidecarBaseUrl, token, protocol: row.protocol }
+ return {
+ id: row.id,
+ name: row.name,
+ baseUrl: row.sidecarBaseUrl,
+ token,
+ protocol: row.protocol,
+ // D104: whether a published news post is said in this server's chat.
+ announceNews: Boolean(Number(row.announceNews)),
+ }
+}
+
+/**
+ * How many servers were enabled when this process last looked. An action's
+ * `cost()` is synchronous and cannot ask the database, so `rust.announce` to
+ * every server is priced at this — refreshed by every poll, which runs
+ * continuously. One until the first poll, which is the least a fleet can be.
+ */
+let enabledCount = 1
+
+function lastEnabledCount() {
+ return enabledCount
}
/** Every enabled server, with tokens, for the poller. */
async function listForPolling() {
const rows = await db.listServers({ enabledOnly: true })
+ enabledCount = rows.length
return rows.map(withToken)
}
@@ -165,6 +186,7 @@ module.exports = {
STALE_AFTER_MS,
withToken,
listForPolling,
+ lastEnabledCount,
listPublic,
getPublic,
listForAdmin,
diff --git a/server/model/visibility/visibility.db.js b/server/model/visibility/visibility.db.js
index 4e0a5e7..6eb63fd 100644
--- a/server/model/visibility/visibility.db.js
+++ b/server/model/visibility/visibility.db.js
@@ -34,7 +34,7 @@ async function getServerPresence(serverId) {
/** Every configured server with its override, in the operator's own order. */
async function listServerPresence() {
return core.query(
- `SELECT id, name, enabled, presence_audience AS presence
+ `SELECT id, name, enabled, presence_audience AS presence, announce_news AS announceNews
FROM ${SERVERS}
ORDER BY sort_order ASC, id ASC`,
)
@@ -52,4 +52,12 @@ async function setServerPresence(serverId, value) {
await core.query(`UPDATE ${SERVERS} SET presence_audience = ? WHERE id = ?`, [value, serverId])
}
-module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence }
+/**
+ * Turns one server's news switch on or off (D104). Existence is the model's
+ * question, asked with a read first, for the reason given above.
+ */
+async function setServerNews(serverId, on) {
+ await core.query(`UPDATE ${SERVERS} SET announce_news = ? WHERE id = ?`, [on ? 1 : 0, serverId])
+}
+
+module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence, setServerNews }
diff --git a/server/model/visibility/visibility.model.js b/server/model/visibility/visibility.model.js
index d796a84..de73f6b 100644
--- a/server/model/visibility/visibility.model.js
+++ b/server/model/visibility/visibility.model.js
@@ -184,6 +184,12 @@ async function describe() {
}
}),
},
+ // D104/D106: whether a published news post is said in each server's chat.
+ // On this page because it is the one that lists every server with a setting
+ // of its own, and it answers the same kind of question — what a server shows.
+ news: {
+ servers: servers.map((s) => ({ id: s.id, name: s.name, enabled: Boolean(s.enabled), on: Boolean(Number(s.announceNews)) })),
+ },
}
}
@@ -197,7 +203,7 @@ async function describe() {
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
-async function update({ fleet, servers, clanRoster } = {}, actor = null) {
+async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
@@ -221,6 +227,17 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
}
}
+ const newsChanges = Object.entries(news || {})
+ for (const [id, value] of newsChanges) {
+ if (typeof value !== 'boolean') {
+ return { ok: false, status: 400, message: `News in game chat is on or off for server ${id}, not "${value}".` }
+ }
+ // eslint-disable-next-line no-await-in-loop
+ if ((await db.getServerPresence(id)) === undefined) {
+ return { ok: false, status: 404, message: `There is no server called ${id}.` }
+ }
+ }
+
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
@@ -229,6 +246,10 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
}
+ for (const [id, on] of newsChanges) {
+ // eslint-disable-next-line no-await-in-loop
+ await db.setServerNews(id, on)
+ }
// What was written, for the controller's audit row. Recorded there rather than
// here because the activity log takes the REQUEST (who, from where), and a
@@ -239,6 +260,7 @@ async function update({ fleet, servers, clanRoster } = {}, actor = null) {
...(fleet !== undefined ? { fleet } : {}),
...(clanRoster !== undefined ? { clanRoster } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
+ ...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
},
}
}
diff --git a/server/permSync.js b/server/permSync.js
index 9f2bdb9..ad53b59 100644
--- a/server/permSync.js
+++ b/server/permSync.js
@@ -215,6 +215,7 @@ async function syncOne(server, { authored, sync, state, force }) {
groups: desired.payload.groups,
grants: desired.payload.grants,
managed: desired.payload.managed,
+ credits: desired.payload.credits,
retire,
})
@@ -333,6 +334,9 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
notLanded: (report.notLanded || []).length,
+ ...(report.creditsApplied !== undefined
+ ? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
+ : {}),
})
}
diff --git a/server/router/admin/visibility.controller.js b/server/router/admin/visibility.controller.js
index 0bab4bf..6fb2a2f 100644
--- a/server/router/admin/visibility.controller.js
+++ b/server/router/admin/visibility.controller.js
@@ -29,8 +29,8 @@ async function read(req, res) {
async function update(req, res) {
try {
- const { fleet, servers, clanRoster } = req.body || {}
- const result = await visibility.update({ fleet, servers, clanRoster }, req.user)
+ const { fleet, servers, clanRoster, news } = req.body || {}
+ const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
diff --git a/server/router/admin/visibility.router.js b/server/router/admin/visibility.router.js
index 92e7f04..87e8f98 100644
--- a/server/router/admin/visibility.router.js
+++ b/server/router/admin/visibility.router.js
@@ -36,8 +36,8 @@ visibilityRouter.get(
visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
- // #swagger.summary = 'Change who may see who is online, or who may see a clan roster'
- // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
+ // #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
+ // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
@@ -46,6 +46,7 @@ visibilityRouter.put(
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
+ body('news').optional().isObject().withMessage('news maps a server id to true or false'),
validate,
visibility.update,
)
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index 715d574..8a2f48e 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -63,7 +63,9 @@ const TIMEOUT_MS = 12000
* a value on a server and give it back (PLAN.md §27); **9** adds the world
* verbs — `/world/monuments`, `/world/owned`, `/world/zone`, `/world/place` and
* `/world/revert` — what an event places in the world and gives back (PLAN.md
- * §28). The bump lands here in the same change as the emitters,
+ * §28); **10** adds the rewards — `/tally/open`, `/tally/snapshot`,
+ * `/tally/close`, `/kits` and `/chat`, and a `credits` field on the permission
+ * sync (PLAN.md §29). 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
@@ -73,7 +75,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
-const PROTOCOL_VERSION = 9
+const PROTOCOL_VERSION = 10
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -362,6 +364,28 @@ const worldPlace = (server, body) => request(server, '/world/place', { method: '
*/
const worldRevert = (server, body) => request(server, '/world/revert', { method: 'POST', body })
+/**
+ * Start counting who takes part in a run (protocol 10). `data.kind` is
+ * `tally.ok` or `tally.error`; a repeated key is answered `repeat: true`.
+ */
+const tallyOpen = (server, body) => request(server, '/tally/open', { method: 'POST', body })
+
+/** Who has taken part in a run so far, scored by the plugin. `tally.error` `no-tally` when it holds none. */
+const tallySnapshot = (server, runId) =>
+ request(server, `/tally/snapshot?runId=${encodeURIComponent(String(runId))}`)
+
+/** Stop counting and forget. `closed: false` means it was already gone, which is a success. */
+const tallyClose = (server, body) => request(server, '/tally/close', { method: 'POST', body })
+
+/** The kits this server's Kits plugin has, and the plugin's reward bounds. `kits.error` when Kits is not loaded. */
+const kits = (server) => request(server, '/kits')
+
+/**
+ * Say one line in the server's chat. `chat.ok` carries `said: false` when the
+ * key was said in the last ten minutes — a retry, answered and not repeated.
+ */
+const chat = (server, body) => request(server, '/chat', { method: 'POST', body })
+
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -388,5 +412,10 @@ module.exports = {
worldZone,
worldPlace,
worldRevert,
+ tallyOpen,
+ tallySnapshot,
+ tallyClose,
+ kits,
+ chat,
joinUrl,
}
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index 78c630a..3bba06b 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -560,6 +560,24 @@ module.exports = {
},
},
},
+ news: {
+ type: 'object',
+ description: 'Whether a published news post is also said in each server’s in-game chat. Off by default (D104).',
+ properties: {
+ servers: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', example: 'main' },
+ name: { type: 'string', example: 'Main · Vanilla' },
+ enabled: { type: 'boolean', example: true },
+ on: { type: 'boolean', example: false },
+ },
+ },
+ },
+ },
+ },
},
},
RustClanAudience: {
@@ -652,6 +670,12 @@ module.exports = {
example: { main: 'public', pvp: null },
},
clanRoster: { $ref: '#/components/schemas/RustClanAudience' },
+ news: {
+ type: 'object',
+ description: 'A server id to whether a published news post is said in its in-game chat.',
+ additionalProperties: { type: 'boolean' },
+ example: { main: true },
+ },
},
},
RustSidecarProbe: {
diff --git a/server/test/engagement.test.js b/server/test/engagement.test.js
index 6445e01..fc191c3 100644
--- a/server/test/engagement.test.js
+++ b/server/test/engagement.test.js
@@ -140,9 +140,10 @@ test('the ceilings are the ones §25.2 decided', () => {
for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
assert.strictEqual(by[id], 'members', id)
}
- // D64: core's team.member.joined already covers it, and kits wait for phase 13.
+ // D64: core's team.member.joined already covers it. The kit reward waited for
+ // the phase that grants one (13b), and a reward is nobody else's news.
assert.strictEqual(by['rust.clan.member.added'], undefined)
- assert.strictEqual(by['rust.kit.entitled'], undefined)
+ assert.strictEqual(by['rust.kit.entitled'], 'owner')
})
test('a clan path survives core\'s url check, colons and all', () => {
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 2f7f0fe..934deb0 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -143,34 +143,58 @@ test('nothing is registered that has nothing behind it yet', () => {
// leases and option sources, and kept budgets here on purpose (D79): a lease
// spends none, and a dimension nothing spends is a dial that does nothing.
// Phase 13a deleted the budgets and actions lines, and registered each budget
- // beside the verb that spends it (below). The announce leg is 13b's.
- assert.deepStrictEqual(api.record.legs, [])
+ // beside the verb that spends it (below). Phase 13b registered the announce
+ // leg (D104) — the post hook is still absent: nothing in game mirrors a post.
+ assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['rust.chat'])
assert.strictEqual(api.record.hooks.post, undefined)
})
-test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
+test('the event verbs are registered, and every budget has a verb that spends it (phases 13a, 13b)', () => {
const { api } = register()
const actions = api.record.eventActions
const budgets = api.record.eventBudgets
- assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.crate.place', 'rust.npc.place', 'rust.zone.open'])
- assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
+ assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
+ 'rust.announce',
+ 'rust.crate.place',
+ 'rust.kit.entitle',
+ 'rust.npc.place',
+ 'rust.participation.collect',
+ 'rust.participation.open',
+ 'rust.zone.open',
+ ])
+ assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
+ 'rust.announcements',
+ 'rust.grants',
+ 'rust.npcs',
+ 'rust.prefabs',
+ 'rust.zone.minutes',
+ ])
- // D79/D89/D97: every dimension has a verb that spends it, and each verb spends
- // exactly ONE — priced from its own declared examples, which is how core
- // decides which cap boxes the switchboard shows. A verb whose dimension moved
- // with its params would hide the other dial from every operator.
+ // D79/D89/D97: every dimension has a verb that spends it, and a verb that
+ // spends anything spends exactly ONE — priced from its own declared examples,
+ // which is how core decides which cap boxes the switchboard shows. A verb
+ // whose dimension moved with its params would hide the other dial from every
+ // operator. The two tally verbs spend nothing: counting people costs no loot.
const spent = new Set()
for (const a of actions) {
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
+ if (a.id.startsWith('rust.participation.')) {
+ assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
+ continue
+ }
assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
spent.add(dims[0])
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
+ // What a teardown can give back is declared honestly: a line said and a
+ // collect filed are not undone.
+ const once = new Set(['rust.announce', 'rust.participation.collect'])
for (const a of actions) {
- assert.strictEqual(a.reversible, 'ledger')
+ assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
+ if (once.has(a.id)) continue
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.
diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js
index 9a06078..600e133 100644
--- a/server/test/permissions.test.js
+++ b/server/test/permissions.test.js
@@ -320,3 +320,61 @@ test('names are lowered, because the store lowers them', () => {
// push one name, find another, and report its own grant as drift for ever.
assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
})
+
+// ── What events granted (phase 13b, D84, D102, D103) ─────────────────────────
+
+test('an event grant is unioned with the admin grants, reaches only its server, and credits the account that played', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const set = {
+ ...authored(),
+ runGrants: [
+ // User 1 won on main with their second account; the grant reaches both
+ // accounts (D28), the credit only the one that took part (D103).
+ { runId: '41', stepId: '7', userId: 1, serverId: 'main', steamId: '7656099', permission: 'Kits.Event', kit: 'event', credit: 1 },
+ // The same permission an admin already grants user 1: one row in the game.
+ { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 1 },
+ // A kit anybody may redeem: a credit and no permission at all.
+ { runId: '41', stepId: '9', userId: 2, serverId: 'main', steamId: '7656002', permission: '', kit: 'starter', credit: 1 },
+ // A win on another server reaches nothing here (D102).
+ { runId: '42', stepId: '1', userId: 2, serverId: 'creative', steamId: '7656002', permission: 'kits.creative', kit: 'c', credit: 1 },
+ // An account unlinked since the win earns its credit nowhere.
+ { runId: '43', stepId: '1', userId: 2, serverId: 'main', steamId: '7656777', permission: '', kit: 'starter', credit: 1 },
+ ],
+ }
+
+ const { payload, rows, hash } = model.buildDesired('main', set)
+ const grantsFor = (steamId) => (payload.grants.find((g) => g.steamId === steamId) || { permissions: [] }).permissions.sort()
+
+ assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold'])
+ assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold'])
+ assert.ok(!payload.managed.includes('kits.creative'))
+ assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2)
+
+ assert.deepEqual(payload.credits, [
+ { steamId: '7656001', kit: 'gold', count: 1 },
+ { steamId: '7656002', kit: 'starter', count: 1 },
+ { steamId: '7656099', kit: 'event', count: 1 },
+ ])
+ // Credits push but never enter the pushed ledger: a use of a kit is not
+ // something in the permission store to retire.
+ assert.ok(!rows.some((r) => r.kind === 'credit'))
+
+ // A revert of the last reward still moves the digest, so it is pushed.
+ const withdrawn = model.buildDesired('main', { ...set, runGrants: set.runGrants.filter((r) => r.stepId !== '9') })
+ assert.notStrictEqual(withdrawn.hash, hash)
+})
+
+test('the permission an admin grants survives an event revert of the same one (D84)', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const event = { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 0 }
+ const before = model.buildDesired('main', { ...authored(), runGrants: [event] })
+ const after = model.buildDesired('main', { ...authored(), runGrants: [] })
+
+ // Nothing to retire: the admin grant still desires every row the event did.
+ assert.deepEqual(model.retirements(before.rows, after.rows), [])
+ assert.deepEqual(after.payload.credits, [])
+})
diff --git a/server/test/rewards.test.js b/server/test/rewards.test.js
new file mode 100644
index 0000000..134bbe9
--- /dev/null
+++ b/server/test/rewards.test.js
@@ -0,0 +1,419 @@
+// ── The rewards (PLAN.md §29, protocol 10) ────────────────────────────────
+//
+// Who took part, what they may redeem, and a line in chat. Every test here is
+// one of the ways the contract's half can look right and be wrong:
+//
+// each recipient mode picks what D101 says, ties in, and a retried draw is the same draw
+// a reward is priced before the tally is read, so at the most it could grant
+// a kit that rewards nothing is refused, and a mode's count is checked
+// one person with two accounts is one reward, and an unlinked winner is named
+// a repeated key writes nothing new; a revert deletes the step's rows and pushes
+// a lost answer is reverted by its key
+// an entitlement is always in force — the site holds it
+// a tally's teardown closes it; "cannot ask" is not "gone"
+// a line to every server succeeds for those that took it and names the rest
+// the news leg speaks only where the switch is on, and retries only when all are down
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+require('../core')._reset()
+require('../core').init(fakeCtx())
+
+const client = require('../sidecarClient')
+const serversDb = require('../model/servers/servers.db')
+const servers = require('../model/servers/servers.model')
+const permDb = require('../model/permissions/permissions.db')
+const linksDb = require('../model/links/links.db')
+const emit = require('../engagement/emit')
+const rewards = require('../eventRewards')
+
+const action = (id) => rewards.ACTIONS.find((a) => a.id === id)
+const source = (id) => rewards.OPTION_SOURCES.find((s) => s.id === id)
+
+const ROWS = {
+ main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
+ alt: { id: 'alt', name: 'Alt', sidecarBaseUrl: 'http://alt:1', sidecarTokenEnc: null, enabled: 1 },
+}
+
+const ok = (data) => ({ ok: true, status: 'ok', data })
+const person = (steamId, score, extra = {}) => ({ steamId, name: `p${steamId}`, seconds: 60, kills: 0, score, joinedAt: Number(steamId), ...extra })
+
+const KIT = { name: 'vip', permission: 'kits.vip', max: 1, cooldown: 0 }
+
+/** Replace the module's collaborators for one test, and put them back after. */
+function stub(t, { kits, snapshot, tallyOpen, tallyClose, chat, polling, links, existing } = {}) {
+ const calls = { grants: [], dirty: [], deleted: [], emitted: [], chat: [], open: [], close: [] }
+ const saved = {
+ getServer: serversDb.getServer,
+ listForPolling: servers.listForPolling,
+ kits: client.kits,
+ tallySnapshot: client.tallySnapshot,
+ tallyOpen: client.tallyOpen,
+ tallyClose: client.tallyClose,
+ chat: client.chat,
+ insertRunGrants: permDb.insertRunGrants,
+ listRunGrantsForStep: permDb.listRunGrantsForStep,
+ deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
+ deleteRunGrantsForKey: permDb.deleteRunGrantsForKey,
+ markDirty: permDb.markDirty,
+ userIdsForSteamIds: linksDb.userIdsForSteamIds,
+ entitled: emit.entitled,
+ }
+
+ serversDb.getServer = async (id) => ROWS[id] || null
+ servers.listForPolling = async () =>
+ (polling || [{ id: 'main' }]).map((s) => ({ name: ROWS[s.id] ? ROWS[s.id].name : s.id, baseUrl: `http://${s.id}:1`, token: 't', ...s }))
+ client.kits = async (server) => (kits ? kits(server) : ok({ kind: 'kits.list', kits: [KIT], maxRecipients: 100 }))
+ client.tallySnapshot = async (server, runId) =>
+ snapshot ? snapshot(server, runId) : ok({ kind: 'tally.snapshot', runId, people: [], maxRecipients: 100 })
+ client.tallyOpen = async (server, body) => {
+ calls.open.push({ server: server.id, body })
+ return tallyOpen ? tallyOpen(server, body) : ok({ kind: 'tally.ok', runId: body.runId })
+ }
+ client.tallyClose = async (server, body) => {
+ calls.close.push({ server: server.id, body })
+ return tallyClose ? tallyClose(server, body) : ok({ kind: 'tally.ok', closed: true })
+ }
+ client.chat = async (server, body) => {
+ calls.chat.push({ server: server.id, body })
+ return chat ? chat(server, body) : ok({ kind: 'chat.ok', said: true })
+ }
+ permDb.insertRunGrants = async (rows) => {
+ calls.grants.push(...rows)
+ return rows.length
+ }
+ permDb.listRunGrantsForStep = async () => existing || []
+ permDb.deleteRunGrantsForStep = async (runId, stepId) => {
+ calls.deleted.push({ runId, stepId })
+ return ['main']
+ }
+ permDb.deleteRunGrantsForKey = async (runId, key) => {
+ calls.deleted.push({ runId, key })
+ return key ? ['main'] : []
+ }
+ permDb.markDirty = async (scope) => calls.dirty.push(scope)
+ linksDb.userIdsForSteamIds = async (ids) =>
+ ids.filter((id) => links && links[id]).map((id) => ({ steamId: id, userId: links[id] }))
+ emit.entitled = (args) => {
+ calls.emitted.push(args)
+ return (args.userIds || []).length
+ }
+
+ t.after(() => {
+ serversDb.getServer = saved.getServer
+ servers.listForPolling = saved.listForPolling
+ Object.assign(client, {
+ kits: saved.kits,
+ tallySnapshot: saved.tallySnapshot,
+ tallyOpen: saved.tallyOpen,
+ tallyClose: saved.tallyClose,
+ chat: saved.chat,
+ })
+ Object.assign(permDb, {
+ insertRunGrants: saved.insertRunGrants,
+ listRunGrantsForStep: saved.listRunGrantsForStep,
+ deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
+ deleteRunGrantsForKey: saved.deleteRunGrantsForKey,
+ markDirty: saved.markDirty,
+ })
+ linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
+ emit.entitled = saved.entitled
+ })
+
+ return calls
+}
+
+// ── Picking ──────────────────────────────────────────────────────────────────
+
+const TALLY = [person('1', 50), person('2', 40), person('3', 40), person('4', 10), person('5', 0)]
+const ids = (list) => list.map((p) => p.steamId)
+
+test('every verb outlives the client, which outlives the sidecar', () => {
+ assert.ok(10000 < client.TIMEOUT_MS)
+ for (const a of rewards.ACTIONS) assert.ok(client.TIMEOUT_MS < a.budgetMs, `${a.id} budgetMs`)
+})
+
+test('everyone is everyone who scored; a score of zero earns nothing (D101)', () => {
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'everyone', null, 'k')), ['1', '2', '3', '4'])
+})
+
+test('top N keeps everybody tied with the last one in', () => {
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 1, 'k')), ['1'])
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 2, 'k')), ['1', '2', '3'])
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 99, 'k')), ['1', '2', '3', '4'])
+})
+
+test('top per cent rounds up and keeps ties — 10% of 11 is 2 (§29.2)', () => {
+ const eleven = Array.from({ length: 11 }, (_, i) => person(String(i + 1), 100 - i))
+ assert.deepStrictEqual(ids(rewards.pickRecipients(eleven, 'topPercent', 10, 'k')), ['1', '2'])
+ // 25% of the four who scored is one, and the tie at 40 does not arise — but
+ // 50% is two, which lands on a tie and takes both.
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 25, 'k')), ['1'])
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 50, 'k')), ['1', '2', '3'])
+})
+
+test('a minimum score is at least X, and zero means everyone who took part', () => {
+ assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'minScore', 40, 'k')), ['1', '2', '3'])
+ assert.strictEqual(rewards.pickRecipients(TALLY, 'minScore', 0, 'k').length, 5)
+})
+
+test('a retried draw draws the same winners, and another key draws differently', () => {
+ const many = Array.from({ length: 40 }, (_, i) => person(String(1000 + i), 0))
+ const first = ids(rewards.pickRecipients(many, 'random', 5, 'key-a'))
+ assert.strictEqual(first.length, 5)
+ assert.deepStrictEqual(ids(rewards.pickRecipients([...many].reverse(), 'random', 5, 'key-a')), first)
+ assert.notDeepStrictEqual(ids(rewards.pickRecipients(many, 'random', 5, 'key-b')), first)
+})
+
+test('a count is checked against its mode', () => {
+ assert.strictEqual(rewards.checkCount('everyone', undefined).ok, true)
+ assert.strictEqual(rewards.checkCount('top', 0).ok, false)
+ assert.strictEqual(rewards.checkCount('top', 101).ok, false)
+ assert.strictEqual(rewards.checkCount('random', 2.5).ok, false)
+ assert.strictEqual(rewards.checkCount('topPercent', 0).ok, false)
+ assert.strictEqual(rewards.checkCount('topPercent', 100).ok, true)
+ assert.strictEqual(rewards.checkCount('minScore', -1).ok, false)
+ assert.strictEqual(rewards.checkCount('minScore', 12.5).ok, true)
+})
+
+test('a reward is priced at the most it could grant, before the tally is read (§29.2)', () => {
+ const cost = action('rust.kit.entitle').cost
+ assert.deepStrictEqual(cost({ recipients: 'top', count: 3 }), { 'rust.grants': 3 })
+ assert.deepStrictEqual(cost({ recipients: 'random', count: 7 }), { 'rust.grants': 7 })
+ for (const mode of ['everyone', 'minScore', 'topPercent']) {
+ assert.deepStrictEqual(cost({ recipients: mode, count: 10 }), { 'rust.grants': rewards.MAX_RECIPIENTS }, mode)
+ }
+})
+
+test('the kit source says what a reward of each kit gives, and flags one that gives nothing', async (t) => {
+ stub(t, {
+ kits: () => ok({
+ kind: 'kits.list',
+ kits: [
+ { name: 'vip', permission: 'kits.vip', max: 0 },
+ { name: 'starter', permission: '', max: 3 },
+ { name: 'free', permission: '', max: 0 },
+ ],
+ }),
+ })
+ const rows = await source('rust.options.kits').resolve({})
+ assert.deepStrictEqual(rows.map((r) => r.value), ['main/vip', 'main/starter', 'main/free'])
+ assert.strictEqual(rows[0].label, 'vip')
+ assert.match(rows[1].label, /open to everyone · 3 uses/)
+ assert.match(rows[2].label, /rewards nothing/)
+})
+
+// ── rust.kit.entitle ─────────────────────────────────────────────────────────
+
+const ENTITLE = { runId: 41, stepId: 7, idempotencyKey: 'key-41-7', params: { kit: 'main/vip', recipients: 'top', count: 2 } }
+
+test('the reward writes one row per linked winner, credits the account that played, and pushes', async (t) => {
+ const calls = stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 100 }),
+ links: { 1: 10, 2: 20 },
+ })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+
+ assert.strictEqual(res.ok, true)
+ assert.deepStrictEqual(res.resources, [{ kind: 'entitlement', ref: 'main:41:7', payload: { serverId: 'main', kit: 'vip' } }])
+ assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.permission, r.credit, r.idemKey]), [
+ [10, '1', 'kits.vip', true, 'key-41-7'],
+ [20, '2', 'kits.vip', true, 'key-41-7'],
+ ])
+ assert.deepStrictEqual(calls.dirty, ['main'])
+ // Top 2 is three people with the tie; the third linked nothing and is named.
+ assert.strictEqual(res.detail.granted, 2)
+ assert.deepStrictEqual(res.detail.missed, ['p3'])
+ assert.deepStrictEqual(calls.emitted[0].userIds, [10, 20])
+})
+
+test('two accounts one person holds are one reward, on the higher-scoring account', async (t) => {
+ const calls = stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5), person('2', 9)] }),
+ links: { 1: 10, 2: 10 },
+ })
+ await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
+ assert.deepStrictEqual(calls.grants.map((r) => r.steamId), ['2'])
+})
+
+test('a kit with no use limit is a permission and no credit; a kit that rewards nothing is refused', async (t) => {
+ let calls = stub(t, {
+ kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: 'kits.vip', max: 0 }] }),
+ snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5)] }),
+ links: { 1: 10 },
+ })
+ await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(calls.grants[0].credit, false)
+
+ calls = stub(t, { kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: '', max: 0 }] }) })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(res.ok, false)
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /gives nobody anything/)
+})
+
+test('a dry run checks the kit and the mode and reads no tally', async (t) => {
+ let read = false
+ const calls = stub(t, { snapshot: () => { read = true; return ok({ kind: 'tally.snapshot', people: [] }) } })
+ const res = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true })
+ assert.deepStrictEqual(res, { ok: true })
+ assert.strictEqual(read, false)
+ assert.strictEqual(calls.grants.length, 0)
+
+ const bad = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true, params: { kit: 'vip', recipients: 'top', count: 2 } })
+ assert.strictEqual(bad.retry, false)
+ assert.match(bad.error, /server\/kit/)
+})
+
+test('a repeated key finds its rows and writes nothing new', async (t) => {
+ const calls = stub(t, { existing: [{ userId: 10 }, { userId: 20 }] })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(res.ok, true)
+ assert.strictEqual(res.detail.repeat, true)
+ assert.strictEqual(calls.grants.length, 0)
+ assert.strictEqual(calls.emitted.length, 0)
+})
+
+test('more qualifying than the server rewards is refused for good, never trimmed (§29.5)', async (t) => {
+ stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 3 }),
+ links: { 1: 1, 2: 2, 3: 3, 4: 4 },
+ })
+ const res = await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
+ assert.strictEqual(res.ok, false)
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /4 people qualify/)
+})
+
+test('no tally on the server is refused for good, and names the verb that opens one', async (t) => {
+ stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
+ const res = await action('rust.kit.entitle').perform(ENTITLE)
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /rust\.participation\.open/)
+})
+
+test('a revert deletes the step\'s rows and pushes; a lost answer is found by its key', async (t) => {
+ const calls = stub(t)
+ const a = action('rust.kit.entitle')
+ assert.deepStrictEqual(await a.revert({ runId: 41, resources: [{ kind: 'entitlement', ref: 'main:41:7' }] }), { ok: true })
+ assert.deepStrictEqual(calls.deleted[0], { runId: '41', stepId: '7' })
+ assert.deepStrictEqual(calls.dirty, ['main'])
+
+ assert.deepStrictEqual(await a.revert({ runId: 41, resources: [], idempotencyKey: 'key-41-7' }), { ok: true })
+ assert.deepStrictEqual(calls.deleted[1], { runId: 41, key: 'key-41-7' })
+})
+
+test('an entitlement is always in force: the site holds it, and a wipe cannot take it', async () => {
+ const res = await action('rust.kit.entitle').reconcile({ runId: 41, resources: [{ ref: 'main:41:7' }] })
+ assert.deepStrictEqual(res, { ok: true, inForce: ['main:41:7'] })
+})
+
+// ── The tally ────────────────────────────────────────────────────────────────
+
+test('a tally crosses with its key, its score and its zone; kills need a whose', async (t) => {
+ const calls = stub(t)
+ const open = action('rust.participation.open')
+ const res = await open.perform({
+ runId: 41,
+ idempotencyKey: 'k',
+ params: { server: 'main', zone: 'Arena', score: 'Both', killsOf: 'npcs', minutes: 30 },
+ })
+ assert.strictEqual(res.ok, true)
+ assert.deepStrictEqual(calls.open[0].body, { runId: '41', key: 'k', score: 'both', killsOf: 'npcs', killWeight: 5, holdMs: 1800000, zone: 'Arena' })
+ assert.strictEqual(res.resources[0].ref, 'main:41')
+
+ const missing = await open.perform({ runId: 41, params: { server: 'main', score: 'kills' } })
+ assert.strictEqual(missing.retry, false)
+})
+
+test('the plugin\'s permanent refusals stay refused; the switch is named', async (t) => {
+ stub(t, { tallyOpen: () => ok({ kind: 'tally.error', reason: 'events-disabled', message: 'events are switched off' }) })
+ const res = await action('rust.participation.open').perform({ runId: 1, params: { server: 'main', score: 'seconds' } })
+ assert.strictEqual(res.retry, false)
+ assert.match(res.error, /switched off/)
+})
+
+test('collect files each person by Steam id, with the website user where linked', async (t) => {
+ stub(t, {
+ snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 3.5, { kills: 1 }), person('2', 1)] }),
+ links: { 1: 10 },
+ })
+ const res = await action('rust.participation.collect').perform({ runId: 41, params: { server: 'main' } })
+ assert.strictEqual(res.participants.length, 2)
+ assert.deepStrictEqual(res.participants[0], {
+ memberKey: '1',
+ userId: 10,
+ score: 3.5,
+ joinedAt: new Date(1).toISOString(),
+ meta: { name: 'p1', seconds: 60, kills: 1 },
+ })
+ assert.strictEqual(res.participants[1].userId, undefined)
+})
+
+test('teardown closes the tally; a lost answer closes it on every server; "cannot ask" keeps it in force', async (t) => {
+ const calls = stub(t, { polling: [{ id: 'main' }, { id: 'alt' }] })
+ const open = action('rust.participation.open')
+ assert.deepStrictEqual(await open.revert({ runId: 41, resources: [{ ref: 'main:41', payload: { serverId: 'main' } }] }), { ok: true })
+ assert.deepStrictEqual(calls.close.map((c) => c.server), ['main'])
+
+ await open.revert({ runId: 41, resources: [] })
+ assert.deepStrictEqual(calls.close.map((c) => c.server), ['main', 'main', 'alt'])
+
+ stub(t, { snapshot: () => ({ ok: false, status: 'http-503' }) })
+ assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: ['main:41'] })
+ stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
+ assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: [] })
+})
+
+// ── Chat ─────────────────────────────────────────────────────────────────────
+
+test('one server: its answer is the step\'s, and a line carries the key and says it is an event\'s', async (t) => {
+ const calls = stub(t, { chat: () => ({ ok: false, status: 'http-503' }) })
+ const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k1', params: { server: 'main', message: ' Go\n now ' } })
+ assert.strictEqual(res.ok, false)
+ assert.notStrictEqual(res.retry, false)
+ assert.deepStrictEqual(calls.chat[0].body, { key: 'k1', message: 'Go now', event: true })
+})
+
+test('every server: a success for those that took it, the rest named (D104, D105)', async (t) => {
+ stub(t, {
+ polling: [{ id: 'main' }, { id: 'alt' }],
+ chat: (server) => (server.id === 'alt' ? { ok: false, status: 'http-503' } : ok({ kind: 'chat.ok', said: true })),
+ })
+ const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k', params: { server: '*', message: 'hi' } })
+ assert.strictEqual(res.ok, true)
+ assert.deepStrictEqual(res.detail, { said: ['Main'], down: ['Alt'] })
+})
+
+test('a line too long is refused on the form', async (t) => {
+ stub(t)
+ const res = await action('rust.announce').perform({ runId: 1, params: { server: 'main', message: 'x'.repeat(rewards.MAX_CHAT + 1) }, verify: true })
+ assert.strictEqual(res.retry, false)
+})
+
+test('the news leg speaks only where the switch is on, keyed by the post', async (t) => {
+ const calls = stub(t, { polling: [{ id: 'main', announceNews: true }, { id: 'alt', announceNews: false }] })
+ const result = await rewards.LEG.dispatch({ id: 9, title: 'Wipe tonight', excerpt: 'Long text' })
+ assert.deepStrictEqual(calls.chat, [{ server: 'main', body: { key: 'news:9', message: 'Wipe tonight' } }])
+ assert.deepStrictEqual(rewards.LEG.classify(result), { outcome: 'done' })
+})
+
+test('the leg: nobody switched on is done; all down is retry; some down is done and named', () => {
+ const { classify } = rewards.LEG
+ assert.deepStrictEqual(classify({ ok: true, outcomes: [] }), { outcome: 'done' })
+ assert.strictEqual(classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }] }).outcome, 'retry')
+ const some = classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }, { server: 'B', state: 'said' }] })
+ assert.strictEqual(some.outcome, 'done')
+ assert.match(some.error, /skipped \(down\): A/)
+ assert.strictEqual(classify({ ok: false, empty: true, outcomes: [] }).outcome, 'terminal')
+})
+
+test('a post with no id is keyed by what it says; a long title is bounded to one line', () => {
+ const line = rewards.chatLine({ title: 'x'.repeat(400) })
+ assert.strictEqual(line.length, rewards.MAX_CHAT)
+ assert.match(rewards.chatKey({ title: 'Hi' }, 'Hi'), /^news:[0-9a-f]{40}$/)
+ assert.strictEqual(rewards.chatLine({ title: null, excerpt: 'Body\ntext' }), 'Body text')
+})
diff --git a/server/test/visibility.test.js b/server/test/visibility.test.js
index 077aad6..98603ec 100644
--- a/server/test/visibility.test.js
+++ b/server/test/visibility.test.js
@@ -185,6 +185,31 @@ test('the clan roster audience defaults to members, and a bad one writes nothing
}
})
+test('news in game chat is off by default, on or off per server, and validated whole (D104, D106)', async () => {
+ const { model, written, restore } = setup({ overrides: { main: null } })
+ const db = require('../model/visibility/visibility.db')
+ const news = []
+ db.setServerNews = async (id, on) => news.push({ id, on })
+ try {
+ // A row that never had the column set reads as off.
+ assert.deepEqual((await model.describe()).news.servers, [{ id: 'main', name: 'MAIN', enabled: true, on: false }])
+
+ const notBoolean = await model.update({ news: { main: 'yes' } })
+ assert.equal(notBoolean.status, 400)
+ const unknown = await model.update({ fleet: 'public', news: { main: true, nope: true } })
+ assert.equal(unknown.status, 404)
+ assert.deepEqual(news, [])
+ assert.deepEqual(written.settings, [], 'the fleet change beside it was not written either')
+
+ const ok = await model.update({ news: { main: true } }, { id: 3 })
+ assert.equal(ok.ok, true)
+ assert.deepEqual(news, [{ id: 'main', on: true }])
+ assert.deepEqual(ok.changed.news, { main: true })
+ } finally {
+ restore()
+ }
+})
+
// ── The public routes ─────────────────────────────────────────────────────
/** A response double recording what a handler answered. */
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 0bc1119..dea9219 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -710,8 +710,8 @@
"tags": [
"Admin · Rust"
],
- "summary": "Change who may see who is online, or who may see a clan roster",
- "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
+ "summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat",
+ "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.",
"responses": {
"200": {
"description": "Saved; answers the new state",
@@ -4513,6 +4513,99 @@
}
}
}
+ },
+ "news": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Whether a published news post is also said in each server’s in-game chat. Off by default (D104)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "servers": {
+ "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": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ },
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Main · Vanilla"
+ }
+ }
+ },
+ "enabled": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "on": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
@@ -5152,6 +5245,37 @@
},
"clanRoster": {
"$ref": "#/components/schemas/RustClanAudience"
+ },
+ "news": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A server id to whether a published news post is said in its in-game chat."
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ }
+ }
+ },
+ "example": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
}
}
}
--
2.49.1
From 42029734ad91d77f2e454725ec38ed9b63fbc292 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Thu, 24 Sep 2026 07:47:01 -0500
Subject: [PATCH 19/51] fix(rust): lowercase the reward option-source ids, and
test the grammar
The first boot against real core refused the whole module at register:
`rust.options.runZones` fails core's EVENT_ID grammar, which is lowercase
dotted segments only. The fake api validates none of it, so 310 green
tests said nothing. The four fixed-choice sources and the chat-server
source are renamed, and entry.test now holds every action, budget,
lease and option-source id against a copy of the grammar.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
server/eventRewards.js | 20 ++++++++++----------
server/test/entry.test.js | 18 ++++++++++++++++++
2 files changed, 28 insertions(+), 10 deletions(-)
diff --git a/server/eventRewards.js b/server/eventRewards.js
index 8c0beb2..938c366 100644
--- a/server/eventRewards.js
+++ b/server/eventRewards.js
@@ -293,11 +293,11 @@ const participationOpen = {
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'Which server counts.' },
- { name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runZones',
+ { name: 'zone', type: 'string', required: false, example: 'Airfield brawl', source: 'rust.options.runzones',
description: 'The name an earlier "Open a zone" step of this run gave its zone. Left blank, the whole server counts (D100).' },
- { name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoreModes',
+ { name: 'score', type: 'string', required: true, example: 'both', source: 'rust.options.scoremodes',
description: 'What earns a place: seconds present, kills, or both.' },
- { name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsOf',
+ { name: 'killsOf', type: 'string', required: false, example: 'npcs', source: 'rust.options.killsof',
description: 'Whose deaths count as a kill: players, NPCs (animals included), or both. The last hit gets it. Needed unless the score is seconds.' },
{ name: 'killWeight', type: 'float', required: false, example: DEFAULT_KILL_WEIGHT,
description: `For a score of both: how many minutes one kill is worth. Left blank, ${DEFAULT_KILL_WEIGHT}.` },
@@ -477,7 +477,7 @@ const kitEntitle = {
params: [
{ name: 'kit', type: 'string', required: true, example: 'main/vip-starter', source: 'rust.options.kits',
description: 'The kit, as server/kit. The reward reaches only that server (D102).' },
- { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientModes',
+ { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who gets it: everyone who scored, the top N, a score of at least X, N drawn at random, or the top X per cent.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone.' },
@@ -641,7 +641,7 @@ const announce = {
version: 1,
budgetMs: BUDGET_MS,
params: [
- { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatServers',
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.chatservers',
description: 'Which server, or * for every server (D105).' },
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
description: `The line, up to ${MAX_CHAT} characters.` },
@@ -805,18 +805,18 @@ const OPTION_SOURCES = [
// Free text: the zones a run will open do not exist when it is authored, and
// the name is checked when the step runs (D100). Declared so the field is
// documented rather than a bare box, and answers nothing.
- fixed('rust.options.runZones', 'Zones this run opens', 'The name an earlier "Open a zone" step of the same run gave its zone. Type it; it is checked when the step runs.', []),
- fixed('rust.options.scoreModes', 'Score', 'What earns a place in a tally.', [
+ fixed('rust.options.runzones', 'Zones this run opens', 'The name an earlier "Open a zone" step of the same run gave its zone. Type it; it is checked when the step runs.', []),
+ fixed('rust.options.scoremodes', 'Score', 'What earns a place in a tally.', [
{ value: 'seconds', label: 'Seconds present' },
{ value: 'kills', label: 'Kills' },
{ value: 'both', label: 'Both — minutes plus a weight per kill' },
]),
- fixed('rust.options.killsOf', 'Kills of', 'Whose deaths count as a kill.', [
+ fixed('rust.options.killsof', 'Kills of', 'Whose deaths count as a kill.', [
{ value: 'players', label: 'Players' },
{ value: 'npcs', label: 'NPCs, animals included' },
{ value: 'both', label: 'Players and NPCs' },
]),
- fixed('rust.options.recipientModes', 'Recipients', 'Who a reward goes to (D101).', [
+ fixed('rust.options.recipientmodes', 'Recipients', 'Who a reward goes to (D101).', [
{ value: 'everyone', label: 'Everyone who scored' },
{ value: 'top', label: 'The top N (ties in)' },
{ value: 'minScore', label: 'A score of at least X' },
@@ -824,7 +824,7 @@ const OPTION_SOURCES = [
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
]),
{
- id: 'rust.options.chatServers',
+ id: 'rust.options.chatservers',
label: 'Chat servers',
description: 'Every enabled server, or * for all of them.',
async resolve() {
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index 934deb0..0653a0e 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -203,6 +203,24 @@ test('the event verbs are registered, and every budget has a verb that spends it
}
})
+test('every event id is one the core grammar accepts (phase 13b)', () => {
+ const { api } = register()
+
+ // Core's EVENT_ID (modules/registries.js), copied rather than imported: this
+ // module cannot reach core's tree. The fake api validates none of it, and
+ // phase 13b's first boot against real core refused the WHOLE module over one
+ // camelCase source id (`rust.options.runZones`). Lowercase dotted segments only.
+ const EVENT_ID = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
+ const ids = [
+ ...api.record.eventActions.map((a) => a.id),
+ ...api.record.eventBudgets.map((b) => b.id),
+ ...api.record.eventOptionSources.map((s) => s.id),
+ ...api.record.eventLeases.map((l) => l.id),
+ ]
+ for (const a of api.record.eventActions) for (const p of a.params) if (p.source) ids.push(p.source)
+ for (const id of ids) assert.match(id, EVENT_ID, id)
+})
+
test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
const { api } = register()
--
2.49.1
From 0cb9bdd1f0a2559aeb494c6a306c846f0db7b22b Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Fri, 25 Sep 2026 01:06:10 -0500
Subject: [PATCH 20/51] feat(rust): the live map (phase 14, protocol 11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PLAN.md §30 as approved, plus D119/D120 from the build.
Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
asks map.info; a new key or hash from the free Rust+ cache (or a render
kept on disk) is fetched in slices, checked against its SHA-256 and stored
in one statement. One fetch per server, a backoff on failure, `stale`
abandons a fetch that straddles a map change. Render now (D109) is
admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
fleet default plus per-server override (D114), the players layer capped by
presence (D113), own dot and online first-party clan mates for a linked
viewer (D115, D117, D118). A layer the viewer may not see is absent from
the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
/map/live; admin /servers/:id/map/fetch and /render; the Map card on the
visibility PUT. Swagger fragment and frozen manifest regenerated.
Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
(labels only when a cell is wide enough to hold one), a legend that lists
hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.
Capability `map` declared for the Android app (phase 15).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
.gitea/workflows/release.yml | 9 +-
ci/bundle.json | 2 +
client/package-lock.json | 8 +
client/package.json | 1 +
client/scripts/checkExternals.js | 51 +-
client/src/api.js | 9 +
client/src/components/MapView.jsx | 358 ++++
client/src/lib/leaflet.js | 28 +
client/src/lib/mapGeometry.js | 92 +
client/src/routes/admin/Visibility.jsx | 230 ++-
client/src/routes/public/ServerDetail.jsx | 5 +
client/test/build.test.js | 37 +-
client/test/mapGeometry.test.js | 92 +
module.json | 2 +-
routes.manifest.json | 25 +
server/boot.js | 7 +
server/db/purge.sql | 4 +
server/db/schema.sql | 60 +
server/mapImages.js | 277 +++
server/mapLive.js | 58 +
server/model/map/map.db.js | 147 ++
server/model/map/map.model.js | 414 +++++
server/model/servers/servers.model.js | 13 +
server/model/visibility/visibility.model.js | 24 +-
server/router/admin/rust.controller.js | 56 +-
server/router/admin/rust.router.js | 33 +
server/router/admin/visibility.controller.js | 69 +-
server/router/admin/visibility.router.js | 5 +-
server/router/public/rust.controller.js | 136 +-
server/router/public/rust.router.js | 43 +
server/sidecarClient.js | 40 +-
server/swagger/doc.js | 211 +++
server/test/map.test.js | 494 ++++++
swagger-fragment.json | 1668 +++++++++++++++++-
34 files changed, 4680 insertions(+), 28 deletions(-)
create mode 100644 client/src/components/MapView.jsx
create mode 100644 client/src/lib/leaflet.js
create mode 100644 client/src/lib/mapGeometry.js
create mode 100644 client/test/mapGeometry.test.js
create mode 100644 server/mapImages.js
create mode 100644 server/mapLive.js
create mode 100644 server/model/map/map.db.js
create mode 100644 server/model/map/map.model.js
create mode 100644 server/test/map.test.js
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index ae78e71..30884d9 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -336,10 +336,13 @@ jobs:
cp -r "server/$d" "$OUT/server/"
done
- # The client half is the BUILT chunk only. `client/src` is source an
- # operator has no use for and core will never read.
+ # The client half is the BUILT chunks only. `client/src` is source an
+ # operator has no use for and core will never read. Every `.js`, not
+ # `entry.js` by name: since D120 the Map tab imports Leaflet's split
+ # chunk from beside it, and a release that shipped the entry alone
+ # would load everywhere and spin for ever on that tab.
mkdir -p "$OUT/client/dist"
- cp client/dist/entry.js "$OUT/client/dist/"
+ cp client/dist/*.js "$OUT/client/dist/"
# Prove the bundle is loadable before it is published: these are the
# paths core's loader resolves out of module.json, and a release whose
diff --git a/ci/bundle.json b/ci/bundle.json
index 3df9ecf..5b46c58 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -38,6 +38,8 @@
"eventWorld.js",
"index.js",
"ingest.js",
+ "mapImages.js",
+ "mapLive.js",
"model",
"package.json",
"permSync.js",
diff --git a/client/package-lock.json b/client/package-lock.json
index fa61633..0700ab6 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -10,6 +10,7 @@
"license": "GPL-3.0-or-later",
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
+ "leaflet": "1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
@@ -1448,6 +1449,13 @@
"node": ">=6"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
diff --git a/client/package.json b/client/package.json
index f57233f..03212bf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -16,6 +16,7 @@
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
+ "leaflet": "1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
diff --git a/client/scripts/checkExternals.js b/client/scripts/checkExternals.js
index 031b226..b54b5ef 100644
--- a/client/scripts/checkExternals.js
+++ b/client/scripts/checkExternals.js
@@ -82,9 +82,10 @@ export function stringMask(src) {
return inString
}
-// Static and dynamic imports that survived into the output. A relative or
-// absolute specifier is a chunk that was split, which this build does not do —
-// `lib` mode with one entry emits one file — so anything here is a bare name.
+// Static and dynamic imports that survived into the output. A relative
+// specifier is a chunk that was split — since D120 there is one, Leaflet's,
+// which the Map tab imports with `import()` — and is `relativeImports`'s
+// concern below; a bare name is this check's.
//
// **This pattern used to require whitespace after `import`, and so could not see
// the one shape the build actually emits.** Minified Rollup output is
@@ -116,6 +117,28 @@ export function bareImports(chunk) {
return [...bare]
}
+/**
+ * Every relative specifier the chunk imports — its split chunks (D120).
+ *
+ * Each one is a file the browser will ask for beside `entry.js`, and core
+ * serves that directory, so it works in development. Whether it SHIPS is
+ * `release.yml`'s business, which is why the script below also asserts every
+ * one of them exists in `dist/`, and `test/build.test.js` asserts the release
+ * copies every `.js` in `dist/` rather than naming `entry.js`. A split chunk the
+ * release forgot is a Map tab that spins for ever on an operator's site while
+ * every check here passes.
+ */
+export function relativeImports(chunk) {
+ const masked = stringMask(chunk)
+ const found = new Set()
+ for (const match of chunk.matchAll(IMPORTS)) {
+ const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
+ if (masked[keywordAt]) continue
+ if (match[1].startsWith('./')) found.add(match[1].slice(2))
+ }
+ return [...found]
+}
+
// Fingerprints from the shared libraries' own source. Each is a string those
// packages ship and this module has no other reason to contain.
//
@@ -161,12 +184,30 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
process.exit(1)
}
- const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
+ const dist = path.dirname(CHUNK)
+ const entry = fs.readFileSync(CHUNK, 'utf8')
+ const problems = []
+
+ // Every chunk, not only the entry: a split chunk that bundled a second React
+ // would load on the tab that imports it and fail there, and nowhere else.
+ for (const file of fs.readdirSync(dist).filter((f) => f.endsWith('.js'))) {
+ for (const p of problemsWith(fs.readFileSync(path.join(dist, file), 'utf8'))) problems.push(`${file}: ${p}`)
+ }
+ for (const name of relativeImports(entry)) {
+ if (!fs.existsSync(path.join(dist, name))) {
+ problems.push(`entry.js imports ./${name}, which is not in dist/ — the chunk would load and that import would fail`)
+ }
+ }
+
if (problems.length) {
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
for (const p of problems) console.error(` - ${p}\n`)
process.exit(1)
}
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
- console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
+ const split = relativeImports(entry)
+ console.log(
+ `OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency` +
+ (split.length ? `; its ${split.length} split chunk(s) (${split.join(', ')}) are present and clean.` : '.'),
+ )
}
diff --git a/client/src/api.js b/client/src/api.js
index 023325a..373d6d1 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -51,6 +51,11 @@ export const servers = {
// Phase 9. The clan list is public (D58): name, colour, score and member count
// name nobody. `board` says whether the list can be trusted right now.
clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`),
+
+ // Phase 14. The map's picture address, geometry and which layers this viewer
+ // gets; then what moves on it, already cut down to this viewer on the server.
+ map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`),
+ mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`),
}
// One clan. Its roster comes back only for a viewer inside the operator's roster
@@ -123,6 +128,10 @@ export const admin = {
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
testServer: (id) =>
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
+ // Phase 14: fetch a server's map picture again, or ask a server with no
+ // picture to draw one — which stalls that game for seconds (D109).
+ fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }),
+ renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }),
}
// ── admin · permissions (R2) ──────────────────────────────────────────────
diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx
new file mode 100644
index 0000000..75bf79e
--- /dev/null
+++ b/client/src/components/MapView.jsx
@@ -0,0 +1,358 @@
+// ── The live map ──────────────────────────────────────────────────────────
+//
+// R9's page, as PLAN.md §30.2 drew it: the picture of the current map under
+// four layers, each with its own switch, polled every ten seconds while the tab
+// is visible (D14) and not at all while it is hidden.
+//
+// **Nothing here decides who may see what.** The server sends only the layers
+// this viewer may see — a hidden one is absent from the answer, not present and
+// hidden — so the checkboxes below are a reader's convenience and never a
+// boundary. A layer the viewer cannot see is still LISTED, disabled, with who
+// can: "staff only" explains an empty map where silence would imply an empty
+// server (§23.3's shape).
+//
+// Leaflet arrives in a chunk of its own when this tab first mounts (D120, see
+// `lib/leaflet.js`). The page draws with circle and div markers only, so
+// Leaflet's image assets are never needed.
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { ErrorState, Loading, useAsync } from '../core.js'
+import Empty from './Empty.jsx'
+import usePolled from '../hooks/usePolled.js'
+import { ago } from '../lib/format.js'
+import { boundsOf, countdown, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
+import api, { BASE } from '../api.js'
+
+const STYLE_ID = 'rust-leaflet-css'
+
+/** The narrowest a grid cell may be on screen, in pixels, and still carry its label. */
+const LABEL_MIN_CELL_PX = 30
+
+/** The layers, in the order the legend lists them, with their marker colours. */
+const LAYERS = [
+ { id: 'world', label: 'Monuments & world events' },
+ { id: 'events', label: 'Site events' },
+ { id: 'players', label: 'Players' },
+ { id: 'bases', label: 'Bases' },
+]
+
+const COLOURS = {
+ monument: '#e8d9a8',
+ cargo: '#4fc3f7',
+ heli: '#ef5350',
+ chinook: '#ffa726',
+ bradley: '#a1887f',
+ supply: '#66bb6a',
+ crate: '#ffee58',
+ event: '#ce93d8',
+ online: '#ffffff',
+ sleeping: '#9e9e9e',
+ tc: '#ff7043',
+ vending: '#26a69a',
+ self: '#00e5ff',
+ mate: '#7cffb2',
+}
+
+const WORLD_NAMES = {
+ cargo: 'Cargo ship',
+ heli: 'Patrol helicopter',
+ chinook: 'Chinook',
+ bradley: 'Bradley APC',
+ supply: 'Supply drop',
+ crate: 'Locked crate',
+}
+
+const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
+
+export default function MapView({ serverId, online }) {
+ const { data: meta, loading, error } = useAsync(() => api.servers.map(serverId), [serverId])
+ const geometry = meta ? meta.geometry : null
+
+ const [leaflet, setLeaflet] = useState(null)
+ const [leafletError, setLeafletError] = useState(null)
+ const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, mates: true })
+
+ useEffect(() => {
+ let alive = true
+ import('../lib/leaflet.js')
+ .then((mod) => {
+ if (!alive) return
+ if (typeof document !== 'undefined' && !document.getElementById(STYLE_ID)) {
+ const style = document.createElement('style')
+ style.id = STYLE_ID
+ style.textContent = mod.css
+ document.head.appendChild(style)
+ }
+ setLeaflet(mod)
+ })
+ .catch((err) => alive && setLeafletError(err))
+ return () => {
+ alive = false
+ }
+ }, [])
+
+ const anyLive = Boolean(meta && (LAYERS.some((l) => meta.layers[l.id].visible) || meta.mates.visible))
+ const live = usePolled(() => api.servers.mapLive(serverId), {
+ key: serverId,
+ intervalMs: (meta && meta.pollMs) || 10_000,
+ enabled: Boolean(geometry) && anyLive,
+ })
+
+ const container = useRef(null)
+ const mapRef = useRef(null)
+ const groups = useRef(null)
+
+ // The map itself: made once per server and geometry, torn down with them.
+ useEffect(() => {
+ if (!leaflet || !geometry || !container.current) return undefined
+ const { L } = leaflet
+ const bounds = boundsOf(geometry)
+ const map = L.map(container.current, {
+ crs: L.CRS.Simple,
+ minZoom: -4,
+ maxZoom: 2,
+ zoomSnap: 0.25,
+ attributionControl: false,
+ // Some things sail off the edge: the rig's cargo ship spent the probe
+ // outside the picture entirely. The view may follow them a little way.
+ maxBounds: L.latLngBounds(bounds).pad(0.5),
+ })
+ if (meta.picture) L.imageOverlay(`${BASE}${meta.picture.path}`, bounds).addTo(map)
+ map.fitBounds(bounds)
+
+ const made = {}
+ for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates']) made[id] = L.layerGroup().addTo(map)
+ // The labels are a layer of their own inside the grid's, shown only when a cell
+ // is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
+ // labels overlap into a wall of text (found on the phase 14 walk).
+ const labels = L.layerGroup()
+ const cellPx = () => geometry.gridCellSize * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize) * 2 ** map.getZoom()
+ const fitLabels = () => {
+ const want = cellPx() >= LABEL_MIN_CELL_PX
+ if (want && !made.grid.hasLayer(labels)) made.grid.addLayer(labels)
+ if (!want && made.grid.hasLayer(labels)) made.grid.removeLayer(labels)
+ }
+ map.on('zoomend', fitLabels)
+
+ const g = grid(geometry)
+ for (const [[x1, z1], [x2, z2]] of g.lines) {
+ L.polyline([toLatLng(geometry, x1, z1), toLatLng(geometry, x2, z2)], {
+ color: '#ffffff',
+ weight: 1,
+ opacity: 0.18,
+ interactive: false,
+ }).addTo(made.grid)
+ }
+ for (const label of g.labels) {
+ L.marker(toLatLng(geometry, label.x, label.z), {
+ interactive: false,
+ keyboard: false,
+ icon: L.divIcon({
+ className: '',
+ html: `${label.text}`,
+ iconSize: null,
+ iconAnchor: [0, 0],
+ }),
+ }).addTo(labels)
+ }
+ fitLabels()
+
+ for (const m of meta.monuments || []) {
+ L.circleMarker(toLatLng(geometry, m.x, m.z), {
+ radius: 4,
+ color: '#000',
+ weight: 1,
+ fillColor: COLOURS.monument,
+ fillOpacity: 0.9,
+ })
+ .bindTooltip(`${escape(m.label)} · ${escape(m.grid || gridLabel(geometry, m.x, m.z) || '')}`)
+ .addTo(made.monuments)
+ }
+
+ mapRef.current = map
+ groups.current = made
+ return () => {
+ map.remove()
+ mapRef.current = null
+ groups.current = null
+ }
+ // `meta` is replaced only when the server changes, with the geometry.
+ }, [leaflet, geometry]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // What moves: every group cleared and redrawn from the latest answer. The
+ // counts are small — a busy server is a few hundred markers — and a redraw is
+ // simpler to get right than a diff.
+ useEffect(() => {
+ const made = groups.current
+ if (!leaflet || !made || !geometry) return
+ const { L } = leaflet
+ const answer = live.data || {}
+ const at = (p) => toLatLng(geometry, p.x, p.z)
+ for (const id of ['world', 'events', 'players', 'bases', 'mates']) made[id].clearLayers()
+
+ for (const w of answer.world || []) {
+ let tip = WORLD_NAMES[w.kind] || w.kind
+ if (w.kind === 'crate' && w.hackLeftSec != null) tip += ` — ${countdown(w.hackLeftSec)} left on the hack`
+ if (w.kind === 'crate' && w.hacked) tip += ' — hacked'
+ dot(L, at(w), COLOURS[w.kind] || COLOURS.crate, w.kind === 'cargo' ? 7 : 5).bindTooltip(escape(tip)).addTo(made.world)
+ }
+
+ const px = (metres) => metres * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize)
+ for (const e of answer.events || []) {
+ if (e.kind === 'zone') {
+ L.circle(at(e), { radius: px(Number(e.radius) || 0), color: COLOURS.event, weight: 2, fillOpacity: 0.12 })
+ .bindTooltip(escape(e.name ? `Event zone · ${e.name}` : 'Event zone'))
+ .addTo(made.events)
+ } else {
+ dot(L, at(e), COLOURS.event, 5).bindTooltip(e.kind === 'npc' ? 'Event NPC' : 'Event crate').addTo(made.events)
+ }
+ }
+
+ for (const p of answer.players || []) {
+ dot(L, at(p), p.online ? COLOURS.online : COLOURS.sleeping, p.online ? 5 : 4)
+ .bindTooltip(escape(`${p.name || p.steamId}${p.online ? (p.sleeping ? ' · sleeping' : '') : ' · asleep, offline'}`))
+ .addTo(made.players)
+ }
+
+ for (const b of answer.bases || []) {
+ dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
+ }
+
+ for (const m of answer.mates || []) {
+ dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2)
+ .bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
+ .addTo(made.mates)
+ }
+ }, [leaflet, geometry, live.data])
+
+ // The legend's checkboxes: a group is on the map or off it.
+ useEffect(() => {
+ const map = mapRef.current
+ const made = groups.current
+ if (!map || !made) return
+ const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, mates: shown.mates }
+ for (const [id, on] of Object.entries(want)) {
+ if (on && !map.hasLayer(made[id])) made[id].addTo(map)
+ if (!on && map.hasLayer(made[id])) map.removeLayer(made[id])
+ }
+ }, [shown, leaflet, geometry])
+
+ const status = useMemo(() => liveStatus(live, anyLive, online), [live, anyLive, online])
+
+ if (loading) return
+ if (error) return
+ if (!geometry) {
+ return (
+
+ )
+ }
+ if (leafletError) return
+
+ return (
+
+ {!meta.picture && (
+
+ This server has no picture of its map, so the layers are drawn on a plain background.
+
+ )
+
+ const hiddenNote = (layer) => {
+ const words = AUDIENCE_WORDS[layer.audience] || AUDIENCE_WORDS.staff
+ if (layer.audience === 'signed_in') return 'Sign in to see this layer.'
+ return `Shown to ${words}.`
+ }
+
+ const swatches = {
+ world: [COLOURS.monument, COLOURS.cargo, COLOURS.heli, COLOURS.crate],
+ events: [COLOURS.event],
+ players: [COLOURS.online, COLOURS.sleeping],
+ bases: [COLOURS.tc, COLOURS.vending],
+ }
+
+ return (
+
+ {row('grid', 'Grid', [], true, null)}
+ {LAYERS.map((l) => {
+ const layer = meta.layers[l.id]
+ let note = layer.visible ? null : hiddenNote(layer)
+ if (l.id === 'players' && layer.cappedByPresence && !layer.visible) {
+ note = `${note} Limited by who may see who is online.`
+ }
+ if (layer.visible && l.id === 'players' && live && live.playersTruncated) note = 'Not every sleeper is shown.'
+ if (layer.visible && l.id === 'bases' && live && live.basesTruncated) note = 'Not every base is shown.'
+ return row(l.id, l.label, swatches[l.id], layer.visible, note)
+ })}
+ {meta.mates.visible &&
+ row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], true, 'Your own position, and clan mates who are online.')}
+ {!meta.mates.visible && meta.mates.on && meta.mates.signedIn && !meta.mates.linked &&
+ row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], false, 'Link your Steam account to see yourself and your clan here.')}
+
+ )
+}
+
+function dot(L, latlng, colour, radius, weight = 1) {
+ return L.circleMarker(latlng, { radius, color: '#000', weight, fillColor: colour, fillOpacity: 0.95 })
+}
+
+/** Tooltips are HTML in Leaflet, and a player's name is text they typed. */
+function escape(text) {
+ return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
+}
+
+function liveStatus(live, anyLive, online) {
+ if (!anyLive) return 'No moving layers are shown to you on this server.'
+ if (live.loading) return 'Asking the server where things are…'
+ const data = live.data
+ if (data && data.live === false) {
+ return online
+ ? 'The server did not say where things are just now; it is asked again every ten seconds.'
+ : 'The server is offline, so nothing is moving on its map.'
+ }
+ if (live.error && !data) return 'Positions could not be loaded.'
+ return live.at ? `Positions as of ${ago(new Date(live.at).toISOString())}, refreshed every ten seconds while this tab is open.` : ''
+}
diff --git a/client/src/lib/leaflet.js b/client/src/lib/leaflet.js
new file mode 100644
index 0000000..2096189
--- /dev/null
+++ b/client/src/lib/leaflet.js
@@ -0,0 +1,28 @@
+// ── Leaflet, and only when the Map tab asks for it ────────────────────────
+//
+// D116 put Leaflet on the map; D120 put it HERE, in a chunk of its own that the
+// Map tab imports with `import()`. Two reasons, and both are about `entry.js`:
+//
+// • `entry.js` is loaded on EVERY page of the site, because core injects every
+// started module's chunk into its shell. Leaflet in it would be ~40 KB gz on
+// the home page, the forum and the news, for a tab most visitors never open.
+// • Leaflet touches `document` and `window` the moment it is evaluated. The
+// chunk is evaluated in Node by `test/registration.test.js`, with a
+// `window` and nothing else — so Leaflet in the entry chunk fails that test
+// at import, before a single registration is checked.
+//
+// Vite emits this as `dist/map-.js` beside `entry.js`. Core serves the
+// directory `client.entry` sits in (MODULE_API.md §3.1), and `release.yml`
+// copies every `.js` in it — `test/build.test.js` holds both ends of that.
+//
+// The ESM build is imported by path: Leaflet 1.9.4's package.json names only its
+// UMD file, and the UMD one would go through CommonJS interop for nothing.
+//
+// The stylesheet comes in as a STRING and is injected once as a `