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:
@@ -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,
|
||||
|
||||
@@ -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