Files
Module-Rust/server/router/player/rust.router.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

113 lines
6.6 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.

// ── 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 } = 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(
'/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.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 callers 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