ACCOUNT_DELETION.md §4.4 (AD10). api.onUserDeleting calls links.unlinkForDeletion,
which runs the player's own unlinkOwned per link, logs rust.account.unlink with
reason 'account deleted', and marks every server's permission sync dirty so the
withdrawn grants reach the game on the next tick. coreApi ^1.20.0; core-ref pinned
to website#232's merge (7bd93b8); engagement manifest regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Registers core's MODULE_API 1.17.0 identity hooks (coreApi ^1.17.0):
onIdentityLinking refuses a Steam id that is another website account's Rust
link (D396); onIdentityLinked writes the Rust link with the PRIMARY KEY as the
race guard, reconciles Teams and raises the linked notice, as /link does.
Unlinking Steam leaves the Rust link (D393); the Rust account page says so.
ci/core-ref.json still pins the 1.15.0 core and must move to the website
branch head carrying 1.17.0 before CI can pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Two findings of the step-2 player walk (2026-09-27, both rigs).
F6, option (b) of the org lead (D186): a code no recent issuer holds -
every made-up one - was still asked of every other enabled server, and
while any of them was down the redeem waited out its whole timeout
(12 s on both rigs). The second pass now skips the servers the board
poll last saw without a connected game; they count as offline without
the wait. Issuers are still asked whatever their state, so a good code
on a down server stays "unsure". Live on the walk core: 338 ms with five
servers down, 360 ms with a rig stopped as well.
F7 (D187): on Carbon a due audit sync went out the moment the sidecar
reconnected, 80 s before "Server startup complete". The worldReady hold
reads the stored hello, which is the OLD boot's until the poll reads the
new one. reasonToSync now also holds while the stored state says the
game is not connected (online 0), which the poll writes the moment the
server goes away. titleSync already held on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The module's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288).
- F13/F14 (D170, D183): `world.expired`, recognisable from protocol 13 by its
`what`, is handed to core as the resource the zone step ledgered
(`world`, `<serverId>:<id>`) through ctx.events.expired, which records it
`expired`. coreApi moves to ^1.11.0 (website#209).
- F8 (D184): `plugin.loaded` / `plugin.unloaded` mark the permission sync dirty
when the plugin added or removed permissions, so an unresolved grant lands on
the next tick instead of the fifteen-minute audit.
- Catalogue: plugin.loaded/unloaded, world.expired and lease.expired are staff
kinds. The last two were never classified (default deny kept them off public
pages); the test now covers every event kind through protocol 13.
- F7: permission and title pushes hold while the stored hello says
`worldReady: false` (a human's "sync now" does not); a failed or refused
permission sync now logs at warn.
- F2 (D185): the killfeed names an NPC attacker — a family (Scientist, Bandit
guard, Bradley APC…) or the prefab without its variant digits (wolf2 → Wolf).
- F5/F6: a link code is asked of the servers that minted one in the last six
minutes first, then of the rest, each group in parallel; "unsure" only when
one of the minting servers is unreachable.
- D182: the admin server list carries the ZoneManager helper's state from the
hello, and the servers page says what a missing or failed helper costs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
A first-party Rust clan is a Team (R5). This module becomes the site's
Team provider and answers core from the plugin's `clans` board. Design
of record: docs/modules/rust/PLAN.md §24, D47-D58.
- The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's
identity is <serverId>:<clanId>:<createdMs> (D52), because the game
restarts clan ids whenever its clan database version changes.
- The provider (D53): getTeams is complete only when every server's
board is fresh, supported and untruncated. It is partial when some
are, and refuses when none are. Freshness is judged by the website's
clock, from when the board's `t` last advanced.
- Only a complete board may mark a clan gone. A board at the game's
100-clan ceiling (D55), or one with an unreadable row, proves nothing
about what it leaves out.
- Leadership is diffed board to board and published (D54). The five clan
events are published as team.* kinds, and written to the Team feed as
members-only lines (D49).
- Core only writes feed items for a Team it already holds. So the last 10
minutes of clan events are re-offered on each board refresh, deduped by
a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key
would be truncated into collisions.
- projectRoster and the clan page share one audience rule (D48): the
clan's linked members and staff by default, re-read from the users row.
The setting lives on Admin > Rust visibility, which also warns about
uMod Clans (D47) and the ceiling.
- Public: GET servers/:id/clans (the list is public, D58) and
GET clans/:externalId. The client adds a Clans tab and
/rust/clans/:externalId, with three module slots for core's notify,
activity and forum contributions (D56).
- Linking and unlinking an account ask core to reconcile Teams (D57).
- The clan kinds are staff-class in the public feed allowlist.
- PROTOCOL_VERSION is now 6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The two defects the phase 6 browser walk found and #6 described but did not
carry. They were written, walked and left uncommitted; `edge` still has the
shapes the walk condemned.
**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 phase 6 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, and the link page showed *Service Unavailable* for all four of
the refusals phase 6 exists to write. All 23 bodies now answer in `message` —
core's `Error` schema, which these routes' own `#swagger.responses` already
referenced, so the annotations stop being a claim the handlers contradict.
`test/errorShape.test.js` drives each outcome rather than grepping for the
field, and asserts the half that is easy to leave behind: a body carrying BOTH
fields renders correctly in a browser and keeps the wrong shape alive for the
next route that copies it.
**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 — the same
person labelled two ways on one site, because a Rust name changes on a whim and
only the admin read joined `rust_players`. A LEFT JOIN, because an account can
be linked and never played on.
123 server tests, 39 client tests, `check:imports`, `check:bundle`,
`check:swagger`, `check:externals` — all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
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