feat(rust): what the site has given a player, as the player reads it

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-22 20:09:51 -05:00
parent 47756d392a
commit 383e89442e
11 changed files with 909 additions and 3 deletions

View File

@@ -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 }

View File

@@ -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']