The site half of §11.3.2, on protocol 14:
- `rust.backpack.size` ("Bigger backpack"): a kit reward with a different
permission (D337) — one rust_perm_run_grants row per website user with
backpacks.size.N, no kit, no credit; the perm mirror pushes it and the run's
revert deletes it. risk change, reversible ledger, a new `rust.backpacks`
budget, rust.kit.entitle's recipient modes including `linked`. Refused with
"Backpacks is not installed on this server" in the dry run too (D199), and a
size the server does not offer is refused by name. Its message is never
silent: empty is the take-back warning (D338).
- `rust.options.backpacksizes`: each server's sizes above its smallest, from
the last hello, grouped by server.
- GET /player/rust/backpacks (own links only, 20 reads a minute) and
GET /admin/users/:id/rust/backpacks (that user's links only). Live, never
stored; a stranger's id is a 404 and never reaches the game.
- Client: a shared Backpack panel (pick server and account, open, read once)
on the account page and the admin user page; the grid draws pages of
pageSize, each slot named by the game (D340, no icons), blueprints by what
they make (D341), overflow marked. Backpacks joins the server's mod line.
Server 548/548 (13 new in test/backpacks.test.js), client 79/79 (5 new),
routes.manifest regenerated against the pinned core (89 routes), swagger
fragment regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
173 lines
11 KiB
JavaScript
173 lines
11 KiB
JavaScript
// ── Player · Rust ─────────────────────────────────────────────────────────
|
||
//
|
||
// Mounted at `/api/v1/player/rust`. The tier's gate is already applied: `player`
|
||
// sits behind `noindex, requireAuth`, so every handler here has a signed-in user
|
||
// and none of them re-implements that check.
|
||
//
|
||
// ── Why this tier exists in phase 1, and what it holds now ────────────────
|
||
//
|
||
// R14 puts this module on all three tiers from the start, and the loader holds
|
||
// `module.json`'s `mounts` against what is actually registered in **both**
|
||
// directions — a declared prefix that never gets a router fails the load. So the
|
||
// declaration and the registration land together or not at all.
|
||
//
|
||
// Phase 1 said this tier would carry the signed-in view of a server — the
|
||
// viewer's own linked Steam identity, their own presence, their own entitlements
|
||
// — and that identity was a later phase. This is that phase: `/links`, `/link`
|
||
// and `DELETE /links/:steamId` are R1, and everything phases 7 and 13 hand out is
|
||
// hung off the row they write.
|
||
//
|
||
// `/servers` stays what it was: the same list the public tier serves, answered on
|
||
// the authenticated tier so per-player detail can be added without moving the
|
||
// address. It delegates to the same model, so the two cannot drift.
|
||
|
||
const core = require('../../core')
|
||
|
||
const express = core.express
|
||
const { body, param, query } = core.validator
|
||
|
||
const rust = require('./rust.controller')
|
||
const { validate, rateLimit } = core.middleware
|
||
|
||
const playerRustRouter = express.Router()
|
||
|
||
// A Steam id as the game states it — `BasePlayer.UserIDString`, a 17-digit
|
||
// SteamID64. Bounded rather than pinned at 17 because the column is a string and
|
||
// a test rig's ids are shorter; what matters is that nothing but digits reaches a
|
||
// `WHERE steam_id = ?`.
|
||
const STEAM_ID_RE = /^[0-9]{5,32}$/
|
||
|
||
/**
|
||
* R1 requires the link code be rate-limited, and this is where that lands.
|
||
*
|
||
* The code is six characters from a 32-glyph alphabet, so guessing one is a
|
||
* 1-in-10⁹ shot — but only while the guesser is made to pay for each attempt.
|
||
* Ten per quarter-hour per IP turns that into centuries; without it a script
|
||
* could work through the space in an afternoon, and phases 7 and 13 make the
|
||
* prize a set of in-game permissions and entitlements rather than a cosmetic
|
||
* badge.
|
||
*
|
||
* Its own limiter rather than core's `accountChangeLimiter`: this is guessing
|
||
* somebody else's secret, not changing your own password, and sharing a counter
|
||
* would mean one of the two silently sets the policy for the other.
|
||
*/
|
||
const linkLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 10,
|
||
label: 'rust-link-code',
|
||
message: 'Too many link attempts. Please try again later.',
|
||
})
|
||
|
||
playerRustRouter.get(
|
||
'/npc-kills',
|
||
// #swagger.tags = ['Player · Rust']
|
||
// #swagger.summary = 'Your kills of each NPC profile'
|
||
// #swagger.description = 'The caller’s kills of RunicNPC’s NPCs, by server and profile, in each server’s current wipe, across every Steam account they have linked (docs/runicnpc/PLAN.md stage 4, D252). Empty with no linked account. Requires a session.'
|
||
/* #swagger.responses[200] = { description: 'The kills', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcOwnKills" } } } } */
|
||
rust.ownNpcKills,
|
||
)
|
||
|
||
/**
|
||
* A backpack read runs on the game's main thread (55–117 ms the first time
|
||
* after a boot, §11.3.1), so a page held open on refresh is not allowed to
|
||
* become a load on the server. Generous for a person, tight for a loop.
|
||
*/
|
||
const backpackLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 20,
|
||
label: 'rust-backpack-read',
|
||
message: 'Too many backpack reads. Please wait a minute.',
|
||
})
|
||
|
||
playerRustRouter.get(
|
||
'/backpacks',
|
||
// #swagger.tags = ['Player · Rust']
|
||
// #swagger.summary = 'Your backpack on one server'
|
||
// #swagger.description = 'One of the caller’s own linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored (PLAN_REDESIGNS §11.3.2). `exists: false` for an account that has never had one. Each stack has its slot, shortname, the game’s English name (`display`), amount, and skin, custom name, blueprint target and nested contents where set; `overflow` marks a stack past the capacity, which moves to the owner’s inventory the next time they open the backpack. 404 for a Steam account the caller has not linked; 409 where the server has no Backpacks. Requires a session; 20 reads a minute.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
|
||
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of the caller’s linked Steam ids.' }
|
||
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such server, or not one of your linked Steam accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
backpackLimiter,
|
||
query('serverId').isString().isLength({ min: 1, max: 64 }),
|
||
query('steamId').isString().matches(STEAM_ID_RE),
|
||
validate,
|
||
rust.ownBackpack,
|
||
)
|
||
|
||
playerRustRouter.get(
|
||
'/economics',
|
||
// #swagger.tags = ['Player · Rust']
|
||
// #swagger.summary = 'Your coin balances'
|
||
// #swagger.description = 'The caller’s Economics balance on every server, per linked Steam account (PLAN_REDESIGNS §11.2.2). Read live from each server; where a server does not answer, the last balance it reported (`source: last-known`), and null where neither knows. A server without Economics is listed with `economics: false` and the reason. `startingKnown: false` means the server’s Economics config could not be read and the starting balance shown is Economics’ default. Empty `accounts` with no linked account. Requires a session.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||
rust.ownEconomics,
|
||
)
|
||
|
||
playerRustRouter.get(
|
||
'/servers',
|
||
// #swagger.tags = ['Player · Rust']
|
||
// #swagger.summary = 'The Rust servers, for a signed-in player'
|
||
// #swagger.description = 'The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.'
|
||
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
|
||
rust.listServers,
|
||
)
|
||
|
||
playerRustRouter.get(
|
||
'/links',
|
||
// #swagger.tags = ['Player · Rust']
|
||
// #swagger.summary = 'The Steam accounts the caller has linked'
|
||
// #swagger.description = 'Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkList" } } } } */
|
||
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']
|
||
// #swagger.summary = 'Link a Steam account with a one-time code from /link in game'
|
||
// #swagger.description = 'The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkResult" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'That Steam account is linked to another website account', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[429] = { description: 'Too many link attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[503] = { description: 'A server could not be reached — the code is still good', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
linkLimiter,
|
||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||
validate,
|
||
rust.confirmLink,
|
||
)
|
||
|
||
playerRustRouter.delete(
|
||
'/links/:steamId',
|
||
// #swagger.tags = ['Player · Rust']
|
||
// #swagger.summary = 'Release a Steam account the caller has linked'
|
||
// #swagger.description = 'Removes the caller’s own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' }
|
||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('steamId').matches(STEAM_ID_RE),
|
||
validate,
|
||
rust.removeLink,
|
||
)
|
||
|
||
module.exports = playerRustRouter
|