feat(rust): identity — a link code from the game, and the Steam id inside core's user page #6

Merged
whitlocktech merged 2 commits from feat/phase-6-identity into edge 2026-09-21 22:25:17 +00:00
Member

R1's identity link, site-side, and R13's first extension slot. A player types /link in game, enters the six-character code here, and an operator sees the result on core's own /admin/users/:id page.

Criterion met for everything the site owns, and walked in a browser. The code-from-the-game half is written down as a walk to run rather than claimed — a code reaches a player and nobody else, so no console can read one.

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 on the game host would be a second thing to reconcile every wipe, answering no question 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 comes 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 hear it again. 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

steam_id is the primary key, 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 sever a link from the admin panel, with an activity row.

The slot, declared in three registries

admin.users.detail is in module.json's extensions (validated against the server registry — naming a client slot there fails the load), registered in server/index.js (with mergeParams, without which req.params.id is undefined and every statement scopes to nobody), and filled in the chunk. Core's users router is requireRole('admin'), so editors and moderators never reach these routes.

It found a hole in this repo's own OpenAPI generator

swaggerFragment.js walked record.routes — the three tiers. A slot router is not registered under a tier, 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, so it is a fourth constant beside TIER_BASE, held to account by the frozen-manifest job — verified by deleting the two paths and watching that check fail.

test/frozenManifest.test.js's mounts agree case was written in phase 1 with this phase named in its comment, and it failed on the first run after the slot was filled. It now also fails when a declared slot contributes no route.

Three defects the browser walk found that 122 green tests did not

  1. Every refusal sentence was invisible. Core's request primitive reads one field — (data && data.message) || res.statusText — and this module has answered { error: … } since phase 1. It got away with it because every failure until now landed in ErrorState on a page whose whole content was missing, where a generic sentence is honest. A form is different: the sentence IS the outcome. The link page showed Service Unavailable for all four refusals this phase exists to write. All 23 error bodies now answer in message — which is also the Error schema these routes' own #swagger.responses already referenced — and test/errorShape.test.js drives each outcome rather than grepping for the field.
  2. The player saw a stale name. /player/rust showed the name recorded at link time while the admin panel showed the one the game last saw. Only the admin read joined rust_players.
  3. Core's activity row collided with a long action name. Raised in core as its own change (D26), not worked around here.

What the walk proved

Against a core at the pinned ref with the module installed, two configured servers (one sidecar up with no game behind it, one address with nothing listening) and three logins:

  • /admin/users/2 renders the Steam id, when it was linked and on which server, when they last played, and per-server all-time totals summed across wipes — under core's own security panel.
  • Staff Unlink removes it, the panel then renders nothing at all (most users have no Rust account), and rust.account.unlink.staff lands in the activity log.
  • The player page: the link row with its own Unlink, the empty state, and the refusal sentence that defect 1 was hiding.
  • A second player deleting the first's link gets the same 404 as one that does not exist; a player reaching the slot route gets 403 from core's gate.
  • R1's rate limit, live: ten attempts pass, the eleventh answers 429.

Checks

122 server tests, 39 client tests, check:imports, check:bundle, check:externals — all green. swagger-fragment.json and routes.manifest.json regenerated against a real core at the pinned ref (16 routes, all documented).

Base branch

edge, per D18.

AI disclosure

  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM

R1's identity link, site-side, and R13's first extension slot. A player types `/link` in game, enters the six-character code here, and an operator sees the result on core's own `/admin/users/:id` page. **Criterion met for everything the site owns, and walked in a browser.** The code-from-the-game half is written down as a walk to run rather than claimed — a code reaches a player and nobody else, so no console can read one. ## 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 on the game host would be a second thing to reconcile every wipe, answering no question 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 comes 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 hear it again. `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 `steam_id` is the primary key, 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 sever a link from the admin panel, with an activity row. ## The slot, declared in three registries `admin.users.detail` is in `module.json`'s `extensions` (validated against the **server** registry — naming a client slot there fails the load), registered in `server/index.js` (with `mergeParams`, without which `req.params.id` is undefined and every statement scopes to nobody), and filled in the chunk. Core's users router is `requireRole('admin')`, so editors and moderators never reach these routes. ### It found a hole in this repo's own OpenAPI generator `swaggerFragment.js` walked `record.routes` — the three tiers. A slot router is not registered under a tier, 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, so it is a fourth constant beside `TIER_BASE`, held to account by the frozen-manifest job — verified by deleting the two paths and watching that check fail. `test/frozenManifest.test.js`'s *mounts agree* case was written in phase 1 with this phase named in its comment, and it failed on the first run after the slot was filled. It now also fails when a declared slot contributes **no** route. ## Three defects the browser walk found that 122 green tests did not 1. **Every refusal sentence was invisible.** Core's request primitive reads one field — `(data && data.message) || res.statusText` — and this module has answered `{ error: … }` since phase 1. It got away with it because every failure until now landed in `ErrorState` on a page whose whole content was missing, where a generic sentence is honest. **A form is different: the sentence IS the outcome.** The link page showed *Service Unavailable* for all four refusals this phase exists to write. All 23 error bodies now answer in `message` — which is also the `Error` schema these routes' own `#swagger.responses` already referenced — and `test/errorShape.test.js` drives each outcome rather than grepping for the field. 2. **The player saw a stale name.** `/player/rust` showed the name recorded at link time while the admin panel showed the one the game last saw. Only the admin read joined `rust_players`. 3. **Core's activity row collided with a long action name.** Raised in core as its own change (D26), not worked around here. ## What the walk proved Against a core at the pinned ref with the module installed, two configured servers (one sidecar up with no game behind it, one address with nothing listening) and three logins: - `/admin/users/2` renders the Steam id, when it was linked and on which server, when they last played, and per-server all-time totals summed across wipes — under core's own security panel. - Staff Unlink removes it, the panel then renders **nothing at all** (most users have no Rust account), and `rust.account.unlink.staff` lands in the activity log. - The player page: the link row with its own Unlink, the empty state, and the refusal sentence that defect 1 was hiding. - A second player deleting the first's link gets the same `404` as one that does not exist; a player reaching the slot route gets `403` from core's gate. - **R1's rate limit, live**: ten attempts pass, the eleventh answers `429`. ## Checks 122 server tests, 39 client tests, `check:imports`, `check:bundle`, `check:externals` — all green. `swagger-fragment.json` and `routes.manifest.json` regenerated against a real core at the pinned ref (16 routes, all documented). ## Base branch `edge`, per D18. ## AI disclosure - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
wtclaude added 2 commits 2026-09-21 14:12:36 +00:00
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
test(rust): grow the mount check for the slot it predicted
All checks were successful
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / frozen-manifest (pull_request) Successful in -50s
0a1e558942
`the manifest and the module's declared mounts agree` was written in phase 1 with
its own exception named in a comment: when `admin.users.detail` arrives, its
routes live on a resource core owns and the test must grow the exception
deliberately rather than let a route outside every declared mount arrive
unnoticed. This is that growth, and the test did its job — it failed on the first
run after the slot was filled.

A route is now legitimate if it is under a declared prefix OR under the mount of
a slot `module.json` declares, and a declared slot that contributes no route
fails too: core never checks that a declared slot was filled (`checkDeclared`
covers `mounts` alone), so this is the only place an exception widening the check
for nothing is noticed. Verified by pointing the slot mount at a path nothing
serves and watching it fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
whitlocktech merged commit f3e274b33d into edge 2026-09-21 22:25:17 +00:00
whitlocktech deleted branch feat/phase-6-identity 2026-09-21 22:25:18 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-Rust#6
No description provided.