feat(rust): what the site has given a player, as the player reads it #10
@@ -88,6 +88,13 @@ export const playerLinks = {
|
|||||||
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
|
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 ─────────────────────────────────────────────────────────────────
|
// ── admin ─────────────────────────────────────────────────────────────────
|
||||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
// **`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
|
// and a save that omits the field leaves the stored credential alone — so an
|
||||||
@@ -215,6 +222,7 @@ export default {
|
|||||||
servers,
|
servers,
|
||||||
playerServers,
|
playerServers,
|
||||||
playerLinks,
|
playerLinks,
|
||||||
|
playerPermissions,
|
||||||
admin,
|
admin,
|
||||||
adminPermissions,
|
adminPermissions,
|
||||||
adminConfig,
|
adminConfig,
|
||||||
|
|||||||
@@ -129,6 +129,138 @@ 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 (
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||||
|
No servers are configured yet
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: '0.76rem' }}>
|
||||||
|
{reach.map((server) => (
|
||||||
|
<span
|
||||||
|
key={server.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line, rgba(255,255,255,0.14))',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '2px 10px',
|
||||||
|
color: server.live ? 'var(--head)' : undefined,
|
||||||
|
opacity: server.live ? 1 : 0.65,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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.live ? 'has it' : 'waiting'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One group or one direct grant, drawn the same way because they read the same. */
|
||||||
|
function HeldRow({ title, subtitle, permissions, reach }) {
|
||||||
|
return (
|
||||||
|
<li className="panel" style={{ padding: '14px 16px' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{title}</div>
|
||||||
|
|
||||||
|
{subtitle && (
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{subtitle}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissions && permissions.length > 0 && (
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
|
||||||
|
{permissions.join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Reach reach={reach} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <Loading />
|
||||||
|
if (error) return <ErrorState error={error} />
|
||||||
|
|
||||||
|
const groups = data.groups || []
|
||||||
|
const grants = data.grants || []
|
||||||
|
|
||||||
|
if (!groups.length && !grants.length) {
|
||||||
|
return (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0, maxWidth: '60ch' }}>
|
||||||
|
Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on
|
||||||
|
the servers they cover.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiting = [...groups, ...grants].some((entry) => entry.reach.some((server) => !server.live))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<HeldRow
|
||||||
|
key={`group:${group.name}`}
|
||||||
|
title={group.title}
|
||||||
|
subtitle={`Rank · joined ${ago(group.since)}`}
|
||||||
|
permissions={group.permissions}
|
||||||
|
reach={group.reach}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{grants.map((grant) => (
|
||||||
|
<HeldRow
|
||||||
|
key={`grant:${grant.permission}:${grant.scope}`}
|
||||||
|
title={grant.permission}
|
||||||
|
subtitle={grant.note || `Granted ${ago(grant.since)}`}
|
||||||
|
reach={grant.reach}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{accounts === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
|
||||||
|
None of this reaches the game yet — link a Steam account above and the site pushes it
|
||||||
|
across on its next sync.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accounts > 0 && waiting && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
|
||||||
|
A server marked <em>waiting</em> has not confirmed it yet. One that is offline catches up
|
||||||
|
when it comes back.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
|
// `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
|
// the person looking at it changes it, and a page that re-asked every twenty
|
||||||
@@ -186,6 +318,14 @@ export default function Account() {
|
|||||||
No Steam account is linked to this profile yet.
|
No Steam account is linked to this profile yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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). */}
|
||||||
|
<div className="field-label" style={{ margin: '30px 0 12px' }}>What you can do in game</div>
|
||||||
|
|
||||||
|
{data && <Held accounts={links.length} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,11 @@
|
|||||||
"path": "/api/v1/player/rust/links",
|
"path": "/api/v1/player/rust/links",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/rust/permissions",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/rust/servers",
|
"path": "/api/v1/player/rust/servers",
|
||||||
|
|||||||
@@ -213,6 +213,46 @@ async function listLinks() {
|
|||||||
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
|
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 ----
|
// ---- what is actually out there ----
|
||||||
|
|
||||||
async function listPushed(serverId) {
|
async function listPushed(serverId) {
|
||||||
@@ -440,6 +480,8 @@ module.exports = {
|
|||||||
deleteGrant,
|
deleteGrant,
|
||||||
findUserByUsername,
|
findUserByUsername,
|
||||||
listLinks,
|
listLinks,
|
||||||
|
listGroupsForUser,
|
||||||
|
listPushedForSteamIds,
|
||||||
listPushed,
|
listPushed,
|
||||||
addPushed,
|
addPushed,
|
||||||
removePushed,
|
removePushed,
|
||||||
|
|||||||
@@ -182,6 +182,94 @@ function catalogueByPermission(rows) {
|
|||||||
.sort((a, b) => a.permission.localeCompare(b.permission))
|
.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.
|
* The whole authored set, read once, in the shape the per-server build wants.
|
||||||
*
|
*
|
||||||
@@ -346,6 +434,7 @@ module.exports = {
|
|||||||
normaliseName,
|
normaliseName,
|
||||||
inScope,
|
inScope,
|
||||||
overview,
|
overview,
|
||||||
|
forPlayer,
|
||||||
readAuthored,
|
readAuthored,
|
||||||
buildDesired,
|
buildDesired,
|
||||||
retirements,
|
retirements,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
const links = require('../../model/links/links.model')
|
const links = require('../../model/links/links.model')
|
||||||
|
const permissions = require('../../model/permissions/permissions.model')
|
||||||
const servers = require('../../model/servers/servers.model')
|
const servers = require('../../model/servers/servers.model')
|
||||||
|
|
||||||
const log = core.logger('player')
|
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 }
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ playerRustRouter.get(
|
|||||||
rust.listLinks,
|
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(
|
playerRustRouter.post(
|
||||||
'/link',
|
'/link',
|
||||||
// #swagger.tags = ['Player · Rust']
|
// #swagger.tags = ['Player · Rust']
|
||||||
|
|||||||
@@ -127,6 +127,58 @@ module.exports = {
|
|||||||
links: { type: 'array', items: { $ref: '#/components/schemas/RustLink' } },
|
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: {
|
RustLinkRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['code'],
|
required: ['code'],
|
||||||
|
|||||||
@@ -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 api = register()
|
||||||
const routes = routesOf(api.record.routes.player['/rust'])
|
const routes = routesOf(api.record.routes.player['/rust'])
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
routes.map((r) => `${r.method} ${r.path}`).sort(),
|
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',
|
||||||
|
],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
171
server/test/playerPermissions.test.js
Normal file
171
server/test/playerPermissions.test.js
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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": {
|
"/api/v1/player/rust/servers": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"RustLinkRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Reference in New Issue
Block a user