Files
Module-Rust/server/test/identityRoutes.test.js
wtclaude baffaa46c9 feat(rust): identity — a link code from the game, and the Steam id inside core's user page
R1's identity link, site-side, and R13's first extension slot. A player types
/link in game, the plugin hands them a six-character code privately, and they
enter it here; the site records who owns which Steam account, and an operator
sees that on core's own `/admin/users/:id` page.

**The site is the author of record and the game holds nothing.** There is no
per-account store in Rust that survives a wipe, and phase 7 needs the site
authoritative anyway — it pushes permissions INTO the game keyed by Steam id. A
copy in the game would be a second thing to reconcile every wipe, for no question
it could answer better.

## D24 — a code is minted by ONE server, so every server is asked

Nothing in six characters says where it came from. The fleet is asked in turn and
the first `link.ok` wins; the others answer `unknown` and nothing happens there,
because a code is only spent at the server that actually holds it. Asking the
player to pick was rejected: a wrong pick would come back indistinguishable from
a wrong code, and that is the one refusal which must not be ambiguous.

**"Every reachable server refused" is not the same answer as "a server was
unreachable."** Collapsing them tells a player whose server is down that their
code is wrong — so they run /link again on that same server and are told the same
thing for as long as it stays down. `unsure` is that case, and it says to try
again rather than to fetch a new code.

## D23 — a Steam id another account holds is refused, never moved

The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7
grants permissions against a link and phase 13 hangs entitlements off it, so a
silent move is an account takeover performed by typing six characters. The
refusal names the holder, because the advice is unusable without it. The INSERT
is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE
that move — and the duplicate-key error is the refusal for the race the check
above cannot close.

The way out is `/unlink` in game, which reaches the site off the ingest feed
rather than through a route (the plugin has no link to delete). D25 adds the
other way out: staff can sever a link from the admin panel, for a player who
cannot reach that Steam account in game.

## The slot, and the hole it found in this repo's own generator

`admin.users.detail` is declared in `module.json` AND registered in `index.js`
AND filled by the chunk — three places, because the server half and the client
half are different registrations that share one name.

`swaggerFragment.js` knew only about tier routers, so the two routes under
`/admin/users/:id` were generated by nothing: a fragment that was internally
consistent and described two routes fewer than the module serves. A slot's mount
is core's and cannot be derived here, so it is a fourth constant beside
`TIER_BASE` — held to account by the frozen-manifest job, which was verified to
catch exactly this by removing the two paths and watching it fail.

## Smaller things worth knowing

- **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page
  re-reads after its own write; it blanks while it re-reads, which is right here
  and is exactly what made it wrong for a poll.
- **Every player-portal nav row needs an `icon`** — core draws one on every row,
  and the client suite says so. This module had no icons file until now, because
  the public header is text buttons.
- The two new frame kinds are STAFF-only. Neither carries a code, but both name a
  Steam id beside a website account's activity, and that join is not a public
  fact about what happened on a server.
- The link code route carries its own rate limiter rather than core's
  `accountChangeLimiter`: this is guessing somebody else's secret, not changing
  your own password, and a shared counter would let one policy set the other.

Protocol 3 on all three declaration sites; 17 new tests, 136 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 08:18:48 -05:00

83 lines
3.4 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 three identity routes, and nothing else new', () => {
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'],
)
})
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 parents 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', 'GET /rust/links'])
for (const route of routesOf(slot.router)) {
assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`)
}
})