Files
Module-Rust/server/test/identityRoutes.test.js
wtclaude 845caef817
All checks were successful
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in -1m29s
feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)
The site half of §11.2.2, on protocol 14:

- `rust.coins.pay` ("Pay coins"): irreversible, reversible none (D335). The
  recipient modes of rust.kit.entitle including `linked`, one payment per
  website user (D103's rule), an optional message with {amount}, a new
  `rust.coins` budget. Refused with "Economics is not installed on this
  server" in the dry run too (D199). Core's key goes to the plugin, so a
  retried step answers `repeat` and pays nobody twice.
- `rust_player_wipe_stats.balance`: the tally's balance, written as a value.
  A nameless balance frame (an offline player) moves nobody's last-seen.
  `economics.wiped` clears the wipe's balances, except at a new save.
- The richest: a Coins leaderboard column on one wipe of a server that
  reports Economics, `sort=balance`, and a `richest` title category
  (Magnate); both rank only above the starting balance (D336).
- GET /player/rust/economics (live, falling back to the last known), and
  GET/POST /admin/users/:id/rust/economics for staff — scoped to the user's
  own links, keyed per submission, an activity row with before and after.
- Client: the account page's Coins, the admin user page's Coins panel with
  its form, the leaderboard column, and Economics in the server's mod line
  (saying when its config could not be read).

Server 535/535 (14 new in test/economics.test.js), client 74/74,
routes.manifest regenerated against the pinned core (87 routes), swagger
fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 07:07:05 -05:00

105 lines
4.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The shape of the identity surface ─────────────────────────────────────
//
// Three properties that are invisible in review and expensive in production:
//
// • **the link route is rate-limited** (R1). Six characters from a 32-glyph
// alphabet is a good code only while a guesser is made to pay per attempt,
// and once phase 7 grants permissions against a link, guessing one is a
// privilege-escalation path rather than a nuisance.
// • **the extension router merges its parent's params**. Without
// `mergeParams`, `req.params.id` is `undefined` and every statement in that
// panel silently scopes to no user — a panel that reads as "this user has no
// Rust account" for everybody.
// • **the extension's paths keep the module's own segment.** Core owns
// `/admin/users/:id`; a bare `/links` would be this module claiming a word on
// a URL it does not own, and the next module to fill a slot would collide.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, fakeApi } = require('./_fakes')
function register(ctx = fakeCtx()) {
require('../core')._reset()
const api = fakeApi()
require('../index')(ctx, api)
return api
}
/** `[{ method, path, handlers }]` for one express router. */
function routesOf(router) {
return router.stack
.filter((layer) => layer.route)
.map((layer) => ({
path: layer.route.path,
method: Object.keys(layer.route.methods)[0].toUpperCase(),
handlers: layer.route.stack.map((s) => s.handle),
}))
}
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',
// Protocol 14 (§11.2.2): the caller's own coin balances. Read-only.
'GET /economics',
'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.
// RunicNPC stage 4 (D252): the caller's own kills of each NPC profile. Read-only.
'GET /npc-kills',
'GET /permissions',
'GET /servers',
'POST /link',
],
)
})
test('redeeming a code is rate-limited, and by a limiter of its own', () => {
const api = register()
const post = routesOf(api.record.routes.player['/rust']).find((r) => r.method === 'POST')
// The fake's `rateLimit` hands back a pass-through carrying the options it was
// given, so the policy itself is assertable — a limiter that was quietly
// removed, or one built with core's `accountChangeLimiter` shared counter,
// both fail here.
const limiter = post.handlers.find((h) => h.options && h.options.label === 'rust-link-code')
assert.ok(limiter, 'POST /link must carry its own rate limiter (R1)')
assert.equal(limiter.options.max, 10)
assert.equal(limiter.options.windowMs, 15 * 60 * 1000)
// First in the chain: a limiter behind the validator would let an attacker
// spend the cheap half of the request unbounded.
assert.equal(post.handlers[0], limiter)
})
test('the admin.users.detail router merges the parent’s params and keeps its own segment', () => {
const api = register()
const slot = api.record.extensions.find((e) => e.slot === 'admin.users.detail')
assert.ok(slot, 'the server half of admin.users.detail must be registered')
assert.equal(slot.router.mergeParams, true)
const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort()
assert.deepEqual(paths, [
'DELETE /rust/links/:steamId',
// Phase 7 filled the same panel with what this person may do in game.
'DELETE /rust/permissions/grants/:grantId',
// Protocol 14 (§11.2.2): their coins, and staff's hand on them.
'GET /rust/economics',
'GET /rust/links',
'GET /rust/permissions',
'POST /rust/economics',
'POST /rust/permissions/grants',
])
for (const route of routesOf(slot.router)) {
assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`)
}
})