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

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

View File

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

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

View File

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

View File

@@ -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',
],
)
})

View File

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