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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user