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