From 383e89442e14a03eef23a59075f059bd36e4c077 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 22 Sep 2026 20:09:51 -0500
Subject: [PATCH 1/2] feat(rust): what the site has given a player, as the
player reads it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 8's website half. Phase 7 made the site the author of in-game
privilege and gave an operator every view of it; this is the other side,
and it is the first time a player can see what they hold without asking
one.
`GET /player/rust/permissions` is self-scoped in SQL and read-only by
construction — a grant a player could change would not be a grant. Three
things make it a different shape from the admin read rather than a
filtered one:
* the scope arithmetic is answered on the server. A client handed `*`
would have to know what the fleet is to say anything, and then
`inScope` exists twice. Each entry carries the servers it reaches,
already resolved and already marked.
* `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
honest, and the alternative is the site claiming to have given
something it has not.
* nothing says WHY it is waiting. An offline server, a permission no
loaded plugin registered and a store that has never seen the account
all look the same from here; telling them apart is an operator's
diagnosis and an inventory of what is installed.
An entitlement that reaches nobody still lists, and the page says so —
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).
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
---
client/src/api.js | 8 +
client/src/routes/player/Account.jsx | 137 +++++++
routes.manifest.json | 5 +
server/model/permissions/permissions.db.js | 42 +++
server/model/permissions/permissions.model.js | 89 +++++
server/router/player/rust.controller.js | 36 +-
server/router/player/rust.router.js | 10 +
server/swagger/doc.js | 52 +++
server/test/identityRoutes.test.js | 12 +-
server/test/playerPermissions.test.js | 171 +++++++++
swagger-fragment.json | 350 ++++++++++++++++++
11 files changed, 909 insertions(+), 3 deletions(-)
create mode 100644 server/test/playerPermissions.test.js
diff --git a/client/src/api.js b/client/src/api.js
index 5ec6d44..c0747fc 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -88,6 +88,13 @@ export const playerLinks = {
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
}
+// What the site has given the caller in game (phase 8). Read-only, and beside
+// `playerLinks` rather than under it: an entitlement exists whether or not an
+// account is linked yet, which is exactly the state worth showing.
+export const playerPermissions = {
+ list: () => req('/player/rust/permissions'),
+}
+
// ── 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
@@ -215,6 +222,7 @@ export default {
servers,
playerServers,
playerLinks,
+ playerPermissions,
admin,
adminPermissions,
adminConfig,
diff --git a/client/src/routes/player/Account.jsx b/client/src/routes/player/Account.jsx
index edbc0b3..8608ce4 100644
--- a/client/src/routes/player/Account.jsx
+++ b/client/src/routes/player/Account.jsx
@@ -129,6 +129,135 @@ function LinkRow({ link, onRemoved }) {
)
}
+/**
+ * Where an entitlement has actually landed.
+ *
+ * The server resolves the scope and marks each server, so this renders an answer
+ * rather than working one out — `*` means nothing to a player, and a second
+ * implementation of the scope arithmetic on the client is a second thing to keep
+ * true (see `forPlayer` in the permission model).
+ */
+function Reach({ reach }) {
+ if (!reach.length) {
+ return (
+
+ No servers are configured yet
+
+ )
+ }
+
+ return (
+
+ {reach.map((server) => (
+
+ {server.live ? '● ' : '○ '}
+ {server.name}
+
+ ))}
+
+ )
+}
+
+/** One group or one direct grant, drawn the same way because they read the same. */
+function HeldRow({ title, subtitle, permissions, reach }) {
+ return (
+
+ {title}
+
+ {subtitle && (
+ {subtitle}
+ )}
+
+ {permissions && permissions.length > 0 && (
+
+ {permissions.join(' · ')}
+
+ )}
+
+
+
+
+
+ )
+}
+
+/**
+ * What the site has given this player in game.
+ *
+ * Its own read, not part of the links read: an entitlement exists whether or not
+ * a Steam account is linked, and a player who has just been given something and
+ * has not linked yet is exactly the person who needs to see both halves at once.
+ */
+function Held({ accounts }) {
+ const { data, loading, error } = useAsync(() => api.playerPermissions.list(), [])
+
+ if (loading) return
+ if (error) return
+
+ const groups = data.groups || []
+ const grants = data.grants || []
+
+ if (!groups.length && !grants.length) {
+ return (
+
+ Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on
+ the servers they cover.
+
+ )
+ }
+
+ const waiting = [...groups, ...grants].some((entry) => entry.reach.some((server) => !server.live))
+
+ return (
+ <>
+
+ {groups.map((group) => (
+
+ ))}
+
+ {grants.map((grant) => (
+
+ ))}
+
+
+ {accounts === 0 && (
+
+ None of this reaches the game yet — link a Steam account above and the site pushes it
+ across on its next sync.
+
+ )}
+
+ {accounts > 0 && waiting && (
+
+ A hollow dot is a server that has not confirmed it yet. One that is offline catches up
+ when it comes back.
+
+ )}
+ >
+ )
+}
+
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
@@ -186,6 +315,14 @@ export default function Account() {
No Steam account is linked to this profile yet.
)}
+
+ {/* 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 2/2] 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