27 Commits

Author SHA1 Message Date
dc3c9689b4 Merge pull request 'feat(rust): Teams from first-party clans (phase 9, protocol 6)' (#12) from feat/phase-9-clans into edge
Reviewed-on: #12
2026-09-23 10:33:13 +00:00
c94271104f feat(rust): Teams from first-party clans (phase 9, protocol 6)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 46s
PR Checks / client-build (pull_request) Successful in 8m3s
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
2026-09-23 05:14:18 -05:00
da1a393702 Merge pull request 'fix(rust): nothing names who is online by default' (#11) from fix/presence-visibility into edge
Reviewed-on: #11
2026-09-23 05:36:23 +00:00
be44839896 fix(rust): nothing names who is online by default
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / server-tests (pull_request) Successful in 8m6s
The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.

The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:

* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
  Below the audience the feed keeps the server's own story (wipe, start,
  shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
  same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
  rust_settings table with an optional per-server override on
  rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
  not taken from the token, so a demotion or a ban applies on the next
  request. Walked: a moderator demoted mid-session lost the roll call on
  the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
  Rust visibility; every save is one activity-log row.

The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 00:30:08 -05:00
480a99f661 Merge pull request 'feat(rust): what the site has given a player, as the player reads it' (#10) from feat/phase-8-player-permissions into edge
Reviewed-on: #10
2026-09-23 01:52:23 +00:00
c4dda5f85c fix(rust): put the word on the pill, not only the dot
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 25s
PR Checks / frozen-manifest (pull_request) Successful in 47s
A filled circle beside a hollow one is the whole difference between "you
have this in game" and "you do not yet", which is more than a shape should
have to carry — and a reader who cannot tell the two apart gets no answer
at all. The pill now reads "<server> · has it" or "<server> · waiting",
which is also what the app's leg says, so the two surfaces describe the
same state in the same words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:32:34 -05:00
383e89442e feat(rust): what the site has given a player, as the player reads it
Phase 8's website half. Phase 7 made the site the author of in-game
privilege and gave an operator every view of it; this is the other side,
and it is the first time a player can see what they hold without asking
one.

`GET /player/rust/permissions` is self-scoped in SQL and read-only by
construction — a grant a player could change would not be a grant. Three
things make it a different shape from the admin read rather than a
filtered one:

  * the scope arithmetic is answered on the server. A client handed `*`
    would have to know what the fleet is to say anything, and then
    `inScope` exists twice. Each entry carries the servers it reaches,
    already resolved and already marked.
  * `live` is the pushed ledger, never the authored row. A grant is not a
    privilege in a game until a sync confirmed it, and phase 7 is careful
    never to record a push that silently did nothing — so "waiting" is
    honest, and the alternative is the site claiming to have given
    something it has not.
  * nothing says WHY it is waiting. An offline server, a permission no
    loaded plugin registered and a store that has never seen the account
    all look the same from here; telling them apart is an operator's
    diagnosis and an inventory of what is installed.

An entitlement that reaches nobody still lists, and the page says so —
authored against the website account, it exists before a Steam id does,
and hiding it until one turns up is the defect the admin user page
shipped in phase 7 (PLAN.md §20.5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:09:51 -05:00
47756d392a Merge pull request 'feat(rust): mod configuration from the site, and an editor that will not rewrite a float' (#9) from feat/phase-7b-config into edge
Reviewed-on: #9
2026-09-22 15:02:18 +00:00
e54ae3afb9 feat(rust): mod configuration from the site, and an editor that will not rewrite a float
All checks were successful
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 7m56s
R18's two tiers: a form generated from a config file's own values, and raw JSON
for what a form cannot express. Admin → Rust mod config, one live round trip per
action, nothing cached between a browser and a game host's disk.

`configEdit.js` is the part that could not be done naively. JavaScript cannot
tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C#
classes — so a read-modify-write silently rewrites every whole-numbered float as
an integer on fields nobody touched, and a plugin that then throws at load does
not come back. It never parses, mutates and re-serialises: it records the SOURCE
SPAN of every value and splices literals into them, so an untouched `1.0` is
still `1.0` and a number an admin types travels as text the whole way (D35/D36).

The bridge's own config is editable with `Host`, `Port` and `ServerId` locked,
in the form and in the raw tier, because either would cut the link carrying the
edit or strand every row this site holds (D38). Credentials render masked with a
reveal; the raw tier shows them (D37) and the audit trail never does.

`rust_config_writes` records every save including the refused and the rolled
back — an operator asking why a setting is not what they set needs to see that
somebody tried.

Three defects a browser walk found that 179 green tests did not:

* every save of the bridge's own config was refused while the page said the
  opposite — a `<select>` whose value matches no `<option>` shows the first one,
  so the reload guess `RunicGateway` was on the wire and "nothing" was on the
  screen;
* `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every
  secondary button in this module has rendered as a primary one since phase 7 —
  here it made the open file and the active tier indistinguishable;
* a save's refusal rendered at the top of a long form, far from the button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:55:28 -05:00
b1abd87c3d Merge pull request 'feat(rust): site-owned permissions — the site is the author, the game is the cache' (#8) from feat/phase-7-permissions into edge
Reviewed-on: #8
2026-09-22 06:53:21 +00:00
f35e70e7d3 docs(rust): record why a nested router needs nothing from the fragment generator
All checks were successful
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / server-tests (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 35s
The opposite of the hole phase 6 found: the registration walk cannot see a
router mounted with `use()`, and swagger-autogen can — it reads a file and
follows its requires, so `/rust/permissions` is generated with the right prefix
from `rust.router.js` alone. Worth a comment where somebody will otherwise add
a fifth constant to make it work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:28:58 -05:00
43147b796a feat(rust): site-owned permissions — the site is the author, the game is the cache
R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:28:32 -05:00
a1b6d155a1 Merge pull request 'fix(rust): answer refusals in the field core reads, and show the name the game last saw' (#7) from fix/phase-6-refusal-sentences into edge
Reviewed-on: #7
2026-09-21 22:39:43 +00:00
0876a1d568 fix(rust): answer refusals in the field core reads, and show the name the game last saw
All checks were successful
PR Checks / server-tests (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / client-build (pull_request) Successful in 7m49s
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
2026-09-21 17:34:21 -05:00
f3e274b33d Merge pull request 'feat(rust): identity — a link code from the game, and the Steam id inside core's user page' (#6) from feat/phase-6-identity into edge
Reviewed-on: #6
2026-09-21 22:25:15 +00:00
0a1e558942 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
`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
2026-09-21 08:19:41 -05:00
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
28e46771b1 Merge pull request 'feat: declare rust as the module's identity capability (phase 5, D16)' (#5) from feat/phase-5-capability into edge
Reviewed-on: #5
2026-09-17 09:22:12 +00:00
8df850f73e feat: declare rust as the module's identity capability
All checks were successful
PR Checks / server-tests (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Successful in -1m4s
Phase 5 is the Android app's leg of this module's read path (R10), and it
gates its Rust navigation on one capability string the way `module-uo`'s five
shard rows gate on `shard`. There was no such string here: the five this module
declared all name a SURFACE, and core flattens every started module's
capabilities into one list, so `servers` is a word another module could declare
tomorrow and silently reveal these screens on a site that does not run Rust.

`rust` is the string only this module can mean. It is asserted against
`module.json`'s own `id` rather than a literal, so the two cannot drift.

The README says why it is not redundant with `id`: `id` is a mount prefix, and
MODULE_API.md §2.9 forbids a client inferring a route from a capability. Gating
on `id` would quietly make those the same thing.

Decided by the org lead as D16, 2026-09-16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 21:55:17 -05:00
7e1f037aad Merge pull request 'feat: the first pages, and what a browser walk found behind them' (#4) from feat/phase-4-first-pages into main
All checks were successful
Release / release (push) Successful in 20s
Reviewed-on: #4
2026-09-17 02:43:27 +00:00
22fd8c5da7 feat: the first pages, and what a browser walk found behind them
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 7m58s
Phase 4. `/rust` is the server list and the module's landing page (D12);
`/rust/servers/:id` is one server with four tabs — feed, leaderboard, who is
on, wipes (D13). Everything selectable lives in the URL, so any view of the
page is a link. The feed and the presence list poll every twenty seconds while
the tab is visible and not at all when it is not (D14); the leaderboard and the
wipe list load once. `site.footer.status` is filled with a live server and
player count (D15).

Nothing on these pages calls a game server. Every field comes from this
module's own tables, which is what the phase criterion is about: the site
renders the last thing each server said while every server is off.

Walking that criterion in a browser against a live rig found four defects, two
of them already shipped in phase 3:

  * An unreachable refresh called `putState` — the whole-row write — with two
    fields, so a host that rebooted lost its hostname, map, size, seed and wipe
    id. The list then read "Offline" with nothing beside it, which is not "here
    is what we know" but "we have never heard of it". `markUnreachable` now
    moves three columns and mentions no others.
  * "Last reported" read `updated_at`, which a FAILED poll writes too — so an
    offline server claimed it had reported just now, every thirty seconds, for
    as long as it stayed down. `last_seen_at` is the new column, moved only by a
    frame that arrived.
  * Feed rows showed a bare time of day, so three events from six weeks ago all
    read as this afternoon once the feed was filtered to a past wipe.
  * `/rust/servers/typo` rendered core's ErrorState under its own heading and
    read "No such server / Something went wrong", sending a reader who mistyped
    a URL looking for an outage.

Also: a detail route (`GET …/servers/:id`), because it is the only route under
that path that can say a server does not exist — the other four answer an empty
list for an id nobody configured, and each of those is a good answer to its own
question.

`useAsync` cannot poll: it blanks its data on every dependency change, so a
twenty-second refresh built on it would clear the killfeed and re-fill it four
times a minute. `hooks/usePolled.js` is the module's own, invisible when it
succeeds and keeping the rows when it fails.

The client test fake was *nearly* core — it prefixed routes without stripping
the trailing separator, so the first module to register an index route failed
the nav check for a link that works in a browser. It now copies core's line
character for character.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 21:40:28 -05:00
5ce711048c Merge pull request 'feat: ingest protocol 2, and keep the record a wipe cannot erase' (#3) from feat/phase-3-protocol-2 into main
All checks were successful
Release / release (push) Successful in 20s
Reviewed-on: #3
2026-09-16 16:37:14 +00:00
f211969ee1 feat: ingest protocol 2, and keep the record a wipe cannot erase
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 7m57s
The module half of the read path. Seven tables, an ingest cursor, four public
routes, and one file whose only job is deciding who may see what.

**The record and the window are different things.** `rust_player_wipe_stats` and
`rust_gather_totals` are permanent and per-wipe, so all-time is those rows SUMmed
rather than a second set of counters that can disagree with them — that is R12's
"per-wipe detail plus all-time rollups" in one table instead of two.
`rust_events` is a bounded 30-day window of raw frames for the killfeed, and
`rust_presence` is a board: replaced wholesale, never appended.

**The feed is a cursor, not a socket, and the header says why.** Core runs Node
20, where a global WebSocket is still behind a flag, so a socket means taking
`ws` — against a release that asserts it has no runtime dependencies (D5). The
deciding argument is the other one though: a socket needs a cursor anyway, for
whatever it missed while the module was restarting, and the catch-up path is the
one that has to be right. A cursor alone is one mechanism exercised every five
seconds rather than two where the second only runs after an outage.

**The cursor advances after the batch, never before.** A crash between the two
re-reads events already counted, which inflates a total; the other order loses
them silently and for ever. One is visible and bounded, the other is invisible
and permanent, so the code fails in the visible direction. A server with no
cursor starts at the sidecar's current END rather than at zero — replaying a
fortnight of deaths into stats for wipes the site never saw is not a catch-up.

**`catalogue.js` is a security boundary, default-deny.** Protocol 2 carries IP
addresses (login attempts, approvals, bans), one player's report about another,
and the grid reference of somebody's base. They are stored, because an operator
chasing ban evasion needs them; they are not served below the admin tier. The
allowlist lives here rather than as a field on the wire, because a boundary
declared by the sender is one a compromised or merely out-of-date game host can
widen — the same reason core's own shard fan-out filters on the serving side. A
kind this build has never heard of is not public, and a test holds the list
against PROTOCOL.md §8.4 so that adding a kind to the protocol without
classifying it fails a build.

`PROTOCOL_VERSION` goes to 2 here in the same change as the emitters, though this
module consumes none of the new frames yet: the sidecar refuses a mismatched
client with a 409, so a module left on 1 would stop being able to read the board
it has been reading all along. A constant that lags the deployment is an outage
with a version number on it.

95 server tests, 20 client tests, every guard green, and `routes.manifest.json`
regenerated against a real core at the pinned ref: 10 routes, all documented,
none of core's moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:37:16 -05:00
9018e55488 Merge pull request 'feat(ci): packaging, release and the frozen manifest' (#2) from feat/phase-2-packaging into main
All checks were successful
Release / release (push) Successful in 21s
Reviewed-on: #2
2026-09-16 10:34:58 +00:00
b33d21d71b feat(ci): packaging, release and the frozen manifest
All checks were successful
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 39s
Phase 2 of docs/modules/rust/PLAN.md. Phase 1 built five guards and ran them by
hand; this repo had no workflows at all, so nothing gated the branch that gets
released and there was no way to release it.

Three pieces:

- **release.yml** — the derived-version engine link, installer and Module-uo
  already run (conventional-commit subjects since the newest tag; module.json's
  version survives as a floor; workflow_dispatch as the backdoor), assembling the
  bundle from an include list and publishing the tarball, the install manifest
  carrying its sha256, and SHA256SUMS. The tag is the number that ships and CI
  stamps it into the bundle's own module.json.
- **pr-checks.yml** — server tests, check:imports, check:bundle, check:swagger,
  the client build, client tests and check:externals, plus frozen-manifest.
- **frozen-manifest** — clones core at the sha pinned in ci/core-ref.json,
  generates its route table without this module and with it, and takes the
  difference. It ran locally against that exact ref: six routes, all documented,
  no core route moved. That is the first proof by a running core that /rust
  collides with nothing — phase 1 could only check it by reading, because core
  mounts /status and /version at a tier root where the loader's own collision
  probe cannot see them.

The bundle carries no node_modules, because the shipped half declares no runtime
dependencies (org lead, phase 2). checkBundle.js holds both halves of that: the
include list still covers everything server/index.js reaches, and no dependency
has appeared without the release learning to pack it. Verified by breaking it —
dropping "model" from the list names the exact edit and exits 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 02:10:07 -05:00
986f460b6e Merge pull request 'feat: the module skeleton and every bundle seam' (#1) from feat/phase-1-skeleton into main
Reviewed-on: #1
2026-09-16 01:30:19 +00:00
862c328176 feat: the module skeleton and every bundle seam
module-rust, id 'rust', built from the Integration Kit's template. Phase 1's job
is the kit's own argument: get every seam working at once with almost nothing in
them, so that afterwards you break exactly one at a time.

What is here:

* /rust on all three tiers, because the loader holds module.json's mounts against
  what is registered in BOTH directions -- so the declaration and the
  registration land together or not at all. The player tier is honestly thin: it
  answers the server list on the authenticated tier, delegating to the same model
  the public tier uses so the two cannot drift while they are meant to be the
  same. It is the address the app will call, registered now rather than moved
  later.
* Two tables. rust_servers is configuration an operator writes; rust_server_state
  is what a sidecar reported. Separate tables because they have different
  writers, lifetimes and audiences -- and because purging observed state while
  keeping the configuration is a thing an operator will want.
* Per-server sidecar tokens through ctx.secretBox, write-only in the API. The
  admin list reports hasToken and never the credential, and an empty token on a
  save leaves the stored one alone -- a form that posts its own blank field would
  otherwise erase a credential every time somebody renamed a server.
* A real sidecar client. It never throws: every call answers {ok, status, data},
  and the status is what tells a wrong URL from a wrong token from a mismatched
  protocol -- all three present as 'the site says my server is offline' and each
  has a different fix.
* The five guards, green: check:imports, check:swagger, check:externals, and both
  suites.

What is deliberately NOT registered: the Team provider, triggers, audiences,
engagement seeds, notification streams, the four event catalogues, and the two
extension slots. Each arrives with the phase that has something real to put in
it, and a test asserts their absence so that removing it is deliberate. A
declared trigger nothing emits and a declared slot nothing fills are both
surfaces an operator can configure and then wait on, which is worse than an
absent one because the absence is visible.

Two corrections to the kit's template, both feedback for a later phase:

* registration.test.js read one page BY NAME to check declared slots are
  rendered, so a module declaring none dies on ENOENT before reaching the loop
  that would have been empty. It now scans every file under src/routes.
* test/_fakes.js supplied validator: {}. An admin router that builds validation
  chains at file scope cannot be required with that, so the fake holds the real
  express-validator -- for the same reason it holds a real express Router.

The kit was right about noGameConnection.test.js: its header predicts that a
module adding a sidecar client will see the check go red, names sidecarClient.js
as the file to allow, and says narrow it rather than delete it. That is exactly
what happened on the first run, and the fix was the one line the header names.

Installed into a real core and verified: the module reaches 'started', publishes
its capability, serves its chunk, and renders a server whose server.hello
originated in a live Rust server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:54:08 -05:00
113 changed files with 30834 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
# Gate every pull request into `main` on a fast, DB-free check suite, so a broken
# build or a failing test can't reach the branch that gets released.
#
# Mirrors RunicGateway/website's pr-checks.yml — this module is two npm packages
# shaped like that repo's `server/` and `client/`, and it is loaded into that
# repo's process, so it is checked the same way with the same Node version.
#
# Phase 1 built all of these checks and ran them BY HAND. That is the gap this
# file closes: a guard nothing invokes is a guard whose state nobody knows.
#
# ── What each job is really asking ───────────────────────────────────────────
#
# The tests are the ordinary half. The `check:*` scripts are the interesting one,
# because they are the acceptance criteria of the module contract itself
# (docs/website/MODULE_API.md Part 5) rather than of this module's behaviour:
#
# • `server: check:imports` — no relative path escapes the module root, and no
# shipped file resolves a bare specifier. A module that reaches into core's
# tree works right up until core moves a file, and the whole boundary is
# worth exactly as much as this check is (§5.1).
#
# • `server: check:bundle` — the release ships everything the entry point can
# reach, and still declares no runtime dependency. Every other job here runs
# against the whole repo, but a release is a SUBSET of it (release.yml
# assembles from the include list in `ci/bundle.json`), and nothing else
# compares the two. Module-uo's v1.0.0 is the cautionary tale: `server/commands/`
# arrived in a cutover, the include list did not learn about it, and the
# module installed and then died at the register stage on the operator's box
# with "Cannot find module './commands/guild.command'". Green in CI, broken
# there — because the subset only exists in the release.
#
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
# failure is invisible in source: `import { useState } from 'react'` is
# correct in every file, and whether it becomes core's React or a bare
# specifier no browser can resolve is decided by vite.config.js. It has to be
# asked of the artifact, so it runs after the build. (The other half — a
# shared dependency being BUNDLED — fails the build itself, from a
# resolution-time guard inside vite.config.js.)
#
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
# module registers, today. Core has no way to generate it: core is a prebuilt
# image, this module arrives on a volume afterwards, and it mounts through a
# call no static parser can follow. So the fragment core merges into
# `/api/docs.json` is whatever this repo committed, and a stale one documents
# a URL surface that does not exist (§2.8).
#
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
# (without this module, then with) and takes the difference. That difference
# is what this module serves, and it is checked three ways: it must match the
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
# core's own routes, and every route in it must have an operation in
# `swagger-fragment.json` — the per-module form of core's rule that a route
# which isn't in the spec doesn't ship (§5.3, §2.8).
#
# Nothing else can ask those questions. Every other check here runs against
# this repo alone, where a mount prefix is a string in `server/index.js` and a
# documented path is a string in a JSON file; whether they name the same URL
# is a fact about a running core, and this is the only job that has one. It is
# also the only thing that can see the blind spot phase 1 had to check by
# reading: core answers several public routes mounted at the TIER ROOT rather
# than under a prefix (`/status`, `/version`), which the loader's own collision
# probe cannot find, so `/rust` being free is now asserted by a core.
#
# Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check
# • Status check patterns: PR Checks / *
# Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown, and keeps matching as jobs are added.
#
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
# Node — no Docker socket, no database.
#
# Scope note: `edge` is gated as well as `main`, though this repo has no `edge`
# branch yet. Multi-phase work lands there first everywhere else in this project,
# and gating only the `main` hop would run these checks for the first time at the
# cutover — the one moment a red build is most expensive to discover. Naming the
# branch before it exists costs nothing; an Android workstream that landed nine
# PRs on an ungated `edge` is why it is here from the start.
name: PR Checks
on:
pull_request:
branches: [main, edge]
# A newer push to the same PR cancels the in-flight run.
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
# often enough to matter, and a red X that means "the network hiccuped" costs a
# reviewer more than it costs the runner to retry, and teaches everyone to re-run
# rather than read a failure.
env:
NPM_CONFIG_FETCH_RETRIES: 5
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
jobs:
server-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: server/package-lock.json
# `npm ci` rather than `npm install`: it also proves the lockfile is in
# sync with package.json instead of silently updating it.
- name: Install server deps
run: npm ci --prefix server
- name: Run server tests
run: npm test --prefix server
- name: Check the module boundary (MODULE_API.md §5.1)
run: npm run check:imports --prefix server
- name: Check the release ships what the module requires
run: npm run check:bundle --prefix server
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
run: npm run check:swagger --prefix server
client-build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
run: npm ci --prefix client
# The build comes FIRST, and that ordering is load-bearing. Two of the
# client tests read `dist/entry.js` — the chunk's externals, and what it
# registers when imported against a fake `window.__rg` — and both skip when
# there is no build. Run the other way round they skip silently in CI, which
# is the worst of both: green, and not asking the question.
- name: Build the client chunk
run: npm run build --prefix client
- name: Run client tests
run: npm test --prefix client
- name: Check the built chunk's externals (MODULE_API.md §3.6)
run: npm run check:externals --prefix client
# ── The URLs this module actually serves ──────────────────────────────────
#
# Everything above proves the module against itself. This proves it against a
# real core: the one place where "the prefix I register" and "the path I
# document" are the same fact rather than two strings that ought to agree.
#
# The module is COPIED into the core checkout, never symlinked — core's loader
# filters its scan with `entry.isDirectory()`, which reports a link as a link
# and skips it silently, so a symlinked module produces a manifest with no
# module routes in it and a diff that looks like the module registering nothing.
frozen-manifest:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
path: module
- uses: actions/setup-node@v4
with:
node-version: 20
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
# commit sha, and `--depth 1` can only fetch a branch tip.
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
run: |
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
REF=$(node -p "require('./module/ci/core-ref.json').ref")
echo "core: $REPO @ $REF"
git clone --quiet "$REPO" core
git -C core checkout --quiet "$REF"
- name: Install core's server deps
run: npm ci --prefix core/server
# Core alone. `--check` first, so a pin that no longer regenerates its own
# committed manifest fails HERE, naming the pin, instead of showing up below
# as this module having removed a route it never touched.
- name: Generate core's manifest without this module
run: |
npm run routes:manifest --prefix core/server -- --check
cp core/server/routes.manifest.json before.json
# The chunk has to exist before the loader will accept the module at all —
# `client.entry` is validated during the manifest step of the scan, and a
# missing one is a load failure, not a warning.
- name: Build the client chunk
run: |
npm ci --prefix module/client
npm run build --prefix module/client
# No `npm ci` on the installed copy, because the shipped half declares no
# runtime dependencies and the release packs no `node_modules` (org lead,
# phase 2). `check:bundle` in the job above is what keeps that true; if it
# ever stops being true, this step and release.yml both grow an install.
- name: Install the module into core
run: |
mkdir -p core/modules/rust
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/rust -xf -
- name: Generate core's manifest with this module
run: |
npm run routes:manifest --prefix core/server
cp core/server/routes.manifest.json after.json
- name: Check the frozen manifest and the fragment's coverage
working-directory: module
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check

View File

@@ -0,0 +1,442 @@
# Build and publish the installable bundle: `module-rust-<version>.tar.gz` plus
# the manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
#
# ── What a release IS here ──────────────────────────────────────────────────
#
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
# the whole module system is shaped around). So a release is not source: it is the
# directory core's loader expects to find at `modules/rust/`, already assembled —
# the prebuilt client chunk, the schema fragment and the OpenAPI fragment — packed
# as it will be unpacked. Core's admin install downloads the tarball, verifies it
# against the `sha256` in the manifest, and unpacks it onto the volume. Nothing
# runs `npm` on the way.
#
# **And nothing is installed into the bundle either**, which is where this repo
# differs from Module-uo: the shipped half declares no runtime dependencies, so
# there is no `npm ci --omit=dev` and no `server/node_modules` in the tarball (org
# lead, phase 2). That is a decision worth being loud about rather than a detail —
# `server/scripts/checkBundle.js` fails the PR that adds a dependency without also
# teaching this file to install and pack it, because a bundle that declares an
# import it does not carry fails the same way a missing directory does.
#
# ── The version is DERIVED, and the declaration is a floor ──────────────────
#
# The engine `link`, `installer` and `Module-uo` already run (MODULE_SYSTEM
# §2.7.1, decision 19 as amended):
#
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases what module.json declares
#
# Module-uo learned this the expensive way: it released only when a merge left
# `module.json` at a version with no release yet — the version DECLARED, never
# computed — and between 2026-08-12 and 2026-08-19 that cost it *every* bundle,
# because nine phases of work landed without anyone touching that line.
#
# **The declared version is kept as a floor, not deleted.** If `module.json` names
# a version above the newest tag, that version releases. Raising it by hand is how
# you say "this one is a minor, whatever the subjects imply", and it is the natural
# place to move when a `coreApi` bump forces the question.
#
# The number that ships is therefore the TAG, and CI writes it into the
# `module.json` inside the bundle at assembly time. The committed `module.json` is
# a floor and a starting point, not a record of the last release — a release engine
# that has to commit a bump back to `main` stops working the day someone protects
# the branch, and this one is protected.
#
# ── The backdoor ────────────────────────────────────────────────────────────
#
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
# new mount, a capability — with no releasable code behind it. Leave `version`
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
# version to publish that. A dispatch releases even when nothing in the log is
# releasable; that is the entire point of pressing the button.
#
# Re-running on a version that is already released is a no-op, so a rerun after an
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
# no-op — see the recovery branch in the plan step. That state is not theoretical:
# `servuo-plugins`' first release pushed its tag and then 401'd on the release API
# because the secret was absent, and without the recovery branch the repo would
# have been stuck there permanently.
#
# This workflow never writes to a branch. It tags and publishes, so `main` needs
# no push exception.
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-Rust):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
# and create the release.
name: Release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: 'Exact version to publish (e.g. 0.1.1). Blank = bump the newest tag by the level below.'
required: false
default: ''
bump:
description: 'Bump level when version is blank: patch | minor | major'
required: false
default: 'patch'
concurrency:
group: release-module-rust
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/Module-Rust
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# Full history: the plan step reads every tag and every subject since the
# newest one, and a shallow clone has neither.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Plan the release (version + changelog)
id: plan
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
EVENT: ${{ github.event_name }}
IN_VERSION: ${{ github.event.inputs.version }}
IN_BUMP: ${{ github.event.inputs.bump }}
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
DECLARED="$(node -p "require('./module.json').version")"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
CURRENT="${LAST_TAG#v}"
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
# `sort -V` orders version strings, so the higher of two is its last
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
# 0.9.0 is exactly the comparison a string sort gets wrong.
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
VERSION=""
if [ -n "${IN_VERSION:-}" ]; then
# The backdoor's exact form. Deliberately unvalidated against the log:
# a human typed it, and the already-released check below is the only
# guard that matters.
VERSION="${IN_VERSION}"
echo "dispatch: publishing the requested version ${VERSION}"
else
LEVEL="$BUMP"
# A dispatch with nothing releasable in the log still releases — that
# is what the button is for. Where the log DOES say something, the
# larger of the two wins rather than the input: pressing the button on
# a log full of `feat:` without touching the dropdown would otherwise
# publish its `patch` default over a minor's worth of work, and a
# version that undersells its own contents cannot be taken back.
if [ "${EVENT:-}" = workflow_dispatch ]; then
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
if [ "$BUMP" = none ]; then
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
elif [ "$LEVEL" != "$BUMP" ]; then
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
fi
fi
if [ -z "$CURRENT" ]; then
VERSION="$DECLARED" # first ever release: ship what is declared
elif [ "$LEVEL" != none ]; then
VERSION="$(bump "$CURRENT" "$LEVEL")"
fi
# The floor. A `module.json` above the newest tag releases at that
# version even when the log says nothing and even when the log says
# patch.
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
VERSION="$DECLARED"
fi
fi
fi
RELEASE=true
if [ -z "$VERSION" ]; then
RELEASE=false
VERSION="$CURRENT"
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
fi
# An existing tag is NOT automatically "nothing to do". A tag with no
# release behind it means a previous run tagged and then died before
# publishing — which is what happened on servuo-plugins' first release,
# where absent secrets took the release API call to 401 after the tag had
# already been pushed. Standing down on the tag alone makes that state
# permanent. Note this deliberately OVERRIDES the RELEASE=false above:
# with the tag in place there is nothing releasable after it, so the
# normal path would stand down, which is why it could never self-heal.
# Anything other than 200/404 — a network failure, a bad token — is not
# evidence of absence, and guessing "no" would publish over a good
# release, so refuse instead.
REUSE_TAG=false
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
case "$REL_HTTP" in
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
REUSE_TAG=true; RELEASE=true ;;
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
esac
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
if [ "$REUSE_TAG" = true ]; then
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
SINCE="$PREV_TAG"
else
CL_RANGE="$RANGE"
SINCE="$LAST_TAG"
fi
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
{
echo "## module-rust v${VERSION}"
echo
echo "Install from the website's Admin → Modules screen by pasting the URL of"
echo "\`module-rust-${VERSION}.json\`, or unpack the tarball onto the modules volume"
echo "as \`modules/rust/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
echo "\`$(node -p "require('./module.json').coreApi")\`."
echo
echo "A Rust server also needs the other two halves of the bridge:"
echo "[Rust-Link](https://${GITEA_HOST}/RunicGateway/Rust-Link) (the sidecar) and"
echo "[Rust-Plugins](https://${GITEA_HOST}/RunicGateway/Rust-Plugins) (the Oxide/Carbon plugin)."
echo
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
echo "$CL_SUBJECTS" | sed 's/^/- /'
echo
echo "### Verifying this download"
echo
echo "Releases are **unsigned** — the \`sha256\` in \`module-rust-${VERSION}.json\` is the"
echo "trust anchor, and the website verifies it before unpacking."
echo
echo '```bash'
echo "sha256sum -c SHA256SUMS --ignore-missing"
echo '```'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
# Before anything is built or tagged, so a repo without secrets fails
# legibly rather than half-publishing: the tag push can succeed on the
# credential actions/checkout left in the local git config while the release
# API call 401s, leaving the repo tagged and unreleased.
- name: Verify release credentials are configured
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
exit 1
fi
echo "Release credentials present."
- name: Build the client chunk
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
npm ci --prefix client
npm run build --prefix client
# ── Assemble exactly what an operator's volume gets ──────────────────
#
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
# whatever it forgot: the day someone adds `server/tools/` with a scratch
# credential in it, an exclude list packs it and nobody finds out.
#
# The list itself lives in `ci/bundle.json`, not here, because it has a
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
# whether the list still covers everything `server/index.js` reaches. One
# declaration, two readers, so a new directory cannot go missing quietly.
- name: Assemble the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
OUT="dist/module-rust-${VERSION}"
rm -rf "$OUT" && mkdir -p "$OUT"
# The manifest core reads — with the RELEASED version written into it.
# The committed `module.json` is a floor, not a record of the last
# release (see the header), so copying it verbatim would ship a bundle
# whose `installed_modules` row and admin screen disagree with the tag it
# came from. This is the one place the derived number becomes the
# module's own.
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
# The two fragments, and the licence the code is under — a bundle that
# ships GPL code without its licence is not distributable.
for f in $(jq -r '.root[]' ci/bundle.json); do
cp "$f" "$OUT/"
done
# The server half, minus what never runs inside core's process. No
# node_modules: the shipped half declares no runtime dependencies, and
# check:bundle is what keeps that true.
mkdir -p "$OUT/server"
for d in $(jq -r '.server[]' ci/bundle.json); do
cp -r "server/$d" "$OUT/server/"
done
# The client half is the BUILT chunk only. `client/src` is source an
# operator has no use for and core will never read.
mkdir -p "$OUT/client/dist"
cp client/dist/entry.js "$OUT/client/dist/"
# Prove the bundle is loadable before it is published: these are the
# paths core's loader resolves out of module.json, and a release whose
# entry point is missing fails on an operator's box with a
# `startup_failed` row instead of here. The version assertion guards the
# rewrite above — a bundle that still carried the declared version would
# install under a number that is not the one it was released as.
node -e '
const fs = require("fs"), path = require("path");
const [root, want] = process.argv.slice(1);
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
if (m.version !== want) {
console.error(`bundle declares ${m.version}, but this is release ${want}`);
process.exit(1);
}
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
}
console.log("bundle contents check: ok");
' "$OUT" "$VERSION"
# ── And that it can actually LOAD ─────────────────────────────────
#
# The check above stats the paths `module.json` declares, which is a real
# question but a shallow one: Module-uo's v1.0.0 passed exactly that and
# was still missing `server/commands/`, because a file reached only by a
# require inside `register()` is named nowhere in `module.json`. This
# resolves every relative require in the assembled tree and asserts the
# target is in it — asked of the artifact, so it also catches a copy that
# half failed or a list naming a path that has since moved.
#
# Run from the SOURCE tree (`server/scripts/` never ships) against the
# assembled bundle.
node server/scripts/checkBundle.js --bundle "$OUT"
tar -C dist -czf "dist/module-rust-${VERSION}.tar.gz" "module-rust-${VERSION}"
rm -rf "$OUT"
SHA="$(sha256sum "dist/module-rust-${VERSION}.tar.gz" | cut -d' ' -f1)"
SIZE="$(stat -c%s "dist/module-rust-${VERSION}.tar.gz")"
# The install manifest. Same shape as the installer's bundle JSON — a
# per-asset sha256 fetched over HTTPS, no signatures — because that is
# the model this project already has and a second one would be a second
# thing to get right (MODULE_SYSTEM.md §1.11).
jq -n \
--arg id "$(node -p "require('./module.json').id")" \
--arg name "$(node -p "require('./module.json').name")" \
--arg version "$VERSION" \
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
--arg artifact "module-rust-${VERSION}.tar.gz" \
--arg sha256 "$SHA" \
--argjson size "$SIZE" \
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-rust-${VERSION}.tar.gz" \
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
> "dist/module-rust-${VERSION}.json"
echo "${SHA} module-rust-${VERSION}.tar.gz" > dist/SHA256SUMS
cat "dist/module-rust-${VERSION}.json"
# Skipped on a recovery run: the tag is already there and is the thing being
# published against.
- name: Tag the release
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
git config user.name 'Runic Gateway CI'
git config user.email 'ci@whitlocktech.net'
git tag -a "$TAG" -m "module-rust ${TAG}"
git push origin "$TAG"
- name: Create the Gitea release and upload the bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
VERSION="${{ steps.plan.outputs.version }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "module-rust-${VERSION}.tar.gz" "module-rust-${VERSION}.json" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done

206
README.md Normal file
View File

@@ -0,0 +1,206 @@
# Module-Rust
The **[Rust](https://rust.facepunch.com/) module** for the Runic Gateway platform: everything that
makes a Runic Gateway site a site *for* Rust. It installs into a website core as
`modules/rust/` and is the platform's second game module, after
[`Module-uo`](https://gitea.whitlocktech.com/RunicGateway/Module-uo).
It is also the first module built from the
[Integration Kit](https://gitea.whitlocktech.com/RunicGateway/Integration-kit) rather than extracted
from the website — which makes it the kit's acceptance test from the inside.
**The repository name is not the module id.** This ships a module whose `id` is `rust`, because the
contract requires `id` to equal the directory core loads it from (`modules/rust/`), and that id is
the prefix of every table and every mount.
## What it is, in one diagram
```
Rust server + Oxide (RunicGateway/Rust-Plugins)
│ loopback TCP, the plugin dials out
rust-link sidecar (RunicGateway/Rust-Link) one per game server
│ HTTPS + WebSocket, bearer token
this module, inside a website core one client per server
│ same-origin JSON
browser · Android app
```
**One server, one sidecar.** A community running six Rust servers runs six pairs and configures six
rows here; the website core never learns there is more than one.
## What ships today
| Surface | Route |
|---|---|
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
| Slot | `site.footer.status` — a live server/player count in core's footer |
**Nothing names who is online by default.** The Online list, every feed item that says a named
player was on the server (connects, respawns, deaths, chat, gather tallies) and the leaderboard's
"last seen" reach **staff** unless an operator widens them in Admin → Rust visibility — fleet-wide,
with an optional override per server. How many players are online is public at every setting. The
viewer's standing is re-read from the database on each request, so a demotion or a ban applies at
once rather than when a token expires.
Every page reads this module's own tables and never calls a game server, which is what lets the
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
sort all live in the URL, so any view of it is a link.
Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
**Teams come from Rust's own clans**, not from the uMod Clans plugin, which is optional and whose
clans never become Teams. A clan's roster reaches its own members and staff unless an operator
widens it in Admin → Rust visibility; its name, colour, score and count are public. The game lists
at most 100 clans per server, and a server at that ceiling answers core partially, so core never
removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
one module**: core's installer refuses a second.
The rest of the module — notifications,
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
both surfaces an operator can configure and then wait on, which is worse than an absent one.
### What a client feature-detects on
`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any
client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10).
Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`.
The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is
not, for two reasons worth writing down before somebody tidies it away:
- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every
started module's capabilities into one list, so `servers` alone is a word another module could
declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean
this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard`
does for `module-uo`.
- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the
directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must
never infer a route from a capability. Gating on `id` would quietly make the two the same thing,
and the day a client builds `/<id>/servers` from it, the contract that lets this module move its
own pages is gone.
An unknown capability is absent, and no route is ever derived from one.
## Build and check
```bash
npm ci --prefix server && npm test --prefix server
npm run check:imports --prefix server
npm run check:bundle --prefix server
npm run check:swagger --prefix server
npm ci --prefix client && npm run build --prefix client
npm run check:externals --prefix client && npm test --prefix client
```
**Build the client BEFORE running its tests** — two of them read the built chunk and skip when there
is none, so a run in the other order passes while asking nothing about the artifact that ships.
Regenerate the OpenAPI fragment whenever a route or an annotation changes:
```bash
npm run swagger --prefix server # writes swagger-fragment.json; commit it
```
`.gitea/workflows/pr-checks.yml` runs all of the above on every pull request, plus one job this
machine cannot run on its own: **frozen-manifest** clones core at the sha pinned in
[`ci/core-ref.json`](ci/core-ref.json), generates its route table without this module and then with
it, and takes the difference. That difference is the URL surface this module serves — checked
against the committed [`routes.manifest.json`](routes.manifest.json), against the OpenAPI fragment
in both directions, and against the rule that **a module may only add**. It is the only thing that
can see whether `/rust` collides with one of the routes core mounts at a tier root (`/status`,
`/version`), which the loader's own collision probe cannot find.
## How it reaches an operator
**An operator never builds anything.** A release is not source: it is the directory core's loader
expects at `modules/rust/`, already assembled — the prebuilt client chunk, the schema fragment and
the OpenAPI fragment, packed as they will be unpacked.
**Every merge to `main` carrying a releasable commit publishes a bundle.** The next version is
computed from conventional-commit subjects since the newest `v*` tag, as in `link`, `installer` and
`Module-uo`: `feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch,
and a `main` that gained none of those cuts no release. The number that ships is the **tag**, and CI
writes it into the `module.json` inside the bundle. `module.json`'s version survives as a **floor**:
name a version there above the newest tag and that version releases, which is how you overrule the
subjects. For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a
capability — run the **Release** workflow by hand (Actions → Release → Run workflow).
Each release carries:
| Asset | What it is |
|---|---|
| `module-rust-<version>.tar.gz` | the directory core expects at `modules/rust/`, already assembled |
| `module-rust-<version>.json` | the install manifest: id, version, `coreApi`, the artifact's URL, size and **`sha256`** |
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before
unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a
second thing to get right.
The tarball is assembled from an **include** list ([`ci/bundle.json`](ci/bundle.json)), never an
exclude list — an exclude list ships whatever it forgot. Tests, scripts, `client/src`, `ci/` and the
dev dependencies are not in it. It carries **no `node_modules`**, because the shipped half declares
no runtime dependencies: everything it needs arrives on `ctx`. `npm run check:bundle` holds both
halves of that — that the list still covers every file `server/index.js` can reach, and that no
runtime dependency has appeared without the release learning to pack it.
## Install it into a core
**From a release**, which is the supported path: in Admin → Modules, paste the URL of that release's
`module-rust-<version>.json`, and restart when the panel offers. Core fetches the manifest, checks
every URL and redirect hop against its own host allowlist, streams the artifact under a byte cap
while hashing it, verifies the `sha256`, inspects the archive in full before unpacking it to a
temporary directory, and only then moves it into `modules/rust/`. Nothing is written into the
modules directory until every check has passed. The allowlist must contain
`gitea.whitlocktech.com` — it is seeded from `MODULE_SOURCE_HOSTS` on a fresh install and is
DB-owned from then on, edited on that same screen. **An empty allowlist forbids every install rather
than permitting all of them.**
**From a working tree**, for development: copy the whole tree to `<website>/modules/rust/` and
restart. **Copy, do not symlink** — the loader lists directory entries and asks each whether it is a
directory; a symlink answers no and the module is skipped in complete silence.
Either way, the module appears when the process restarts: the volume is read at require time.
Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar
printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
stored encrypted through core's own secret box and never returned to any client; the panel reports
only whether one is set.
`POST /api/v1/admin/rust/servers/:id/test` probes a sidecar and reports what came back in one word.
That is the route that tells a wrong URL from a wrong token from a mismatched protocol version —
all three present as "the site says my server is offline" and each has a different fix.
## The protocol is a contract
`PROTOCOL_VERSION` in `server/sidecarClient.js` is sent on every request as `X-RustLink-Version`,
and a sidecar speaking a different one answers `409` rather than serving something this module will
mis-parse. It must agree with the sidecar's own constant and with `overlay.toml` in the plugin repo.
Canonical spec:
[`docs/rust-link/PROTOCOL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/PROTOCOL.md).
The module's own design of record is
[`docs/modules/rust/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust/PLAN.md).
## Licence
GPL-3.0-or-later. See [LICENSE.md](LICENSE.md).

51
ci/bundle.json Normal file
View File

@@ -0,0 +1,51 @@
{
"$comment": [
"What a release copies into the bundle, declared ONCE. Read by .gitea/workflows/release.yml when",
"it assembles the tarball, and by server/scripts/checkBundle.js when CI asks whether that list",
"still covers everything the module's entry point can reach.",
"",
"This is an INCLUDE list on purpose. An exclude list ships whatever it forgot: the day someone",
"adds server/tools/ with a scratch credential in it, an exclude list packs it and nobody finds",
"out. The cost of that choice is that a new top-level directory silently drops OUT of every",
"release instead — which is exactly what happened to Module-uo between v0.3.0 and v1.0.0, where",
"server/commands/ arrived with a cutover, the list did not learn about it, and the module",
"installed and then died at the register stage on the operator's box. checkBundle.js exists so",
"that cannot happen twice, and it runs on the PR that adds the directory.",
"",
"server[] entries are paths under server/; root[] and generated[] are paths under the module",
"root.",
"",
"node_modules is NOT here, and its absence is asserted rather than assumed: this module declares",
"no runtime dependencies (everything the shipped half needs arrives on ctx), so the release runs",
"no npm ci and packs no dependency tree. checkBundle.js fails the PR that adds a `dependencies`",
"entry to server/package.json without also teaching the release to pack it — because a module",
"whose bundle silently lacks its own dependency fails the same way the missing directory did.",
"",
"generated[] ships but is not copied — release.yml writes module.json through jq to stamp the",
"released version into it, since the committed one is a floor rather than a record of the last",
"release. It is listed because server/index.js requires it, and a check that did not know it",
"ships would report the module's own manifest as missing from the bundle."
],
"server": [
"boot.js",
"catalogue.js",
"configEdit.js",
"core.js",
"db",
"index.js",
"ingest.js",
"model",
"package.json",
"permSync.js",
"router",
"sidecarClient.js"
],
"root": [
"swagger-fragment.json",
"LICENSE.md",
"README.md"
],
"generated": [
"module.json"
]
}

6
ci/core-ref.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "efa9db73304552dd8bb7a84030b258c6320f79f7",
"refName": "main @ MODULE_API 1.10.0, the Asset Bridge cutover 2 of 5 (website#202)"
}

1792
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
client/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "rust-module-client",
"version": "0.1.0",
"private": true,
"description": "Client half of the Rust module — a prebuilt ESM chunk core injects into its own SPA",
"license": "GPL-3.0-or-later",
"type": "module",
"scripts": {
"build": "vite build",
"test": "node --test",
"check:externals": "node scripts/checkExternals.js"
},
"engines": {
"node": ">=20"
},
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env node
// ── §5.1's client half — what stayed a bare import in the built chunk ──────
//
// The server half's boundary check reads source. The client half's has to read
// the BUILD OUTPUT, because the failure it exists to catch is invisible in
// source: `import { useState } from 'react'` is correct in every file, and
// whether it ends up as core's React or as a second copy welded into the chunk
// is decided by vite.config.js's aliases. A missed alias changes nothing you can
// see until a hook throws in the browser.
//
// So: build, then ask the artifact two questions.
//
// 1. **Is there a bare import left?** There must not be. Aliased shims are
// bundled, so a surviving bare specifier means an alias missed and
// `external` caught it — the loud failure the config prefers, but still a
// failure, and better found here than by a browser refusing to load.
// 2. **Did a shared dependency get bundled?** React's own source has
// fingerprints that no module of ours would contain by accident. Finding
// one means the chunk carries a second React, which is the silent version
// of the same mistake and the one worth the fingerprint check.
//
// Run after `npm run build`, in CI, on the artifact that ships.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
/**
* Which characters of the chunk are inside a string, template or comment.
*
* **A check that reads code with a regexp fails on code that talks about
* itself.** The first real chunk this script ever saw — slice 3's, the first
* with any content in it — was rejected for importing `" }),\n !l && …`,
* because a button reading "Approve and import" put the token `import`
* immediately before a quote and the pattern could not tell that from a
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
* anything.
*
* The server half hit the same wall from the other side and answered it the same
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
* regexp. There is no regexp that distinguishes a keyword from the same letters
* inside a string, because that distinction is a property of the parse.
*
* A mask rather than a rewrite, because the two halves of a real import — the
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
* would take the answer with the noise.
*/
export function stringMask(src) {
const inString = new Uint8Array(src.length)
let i = 0
while (i < src.length) {
const c = src[i]
const two = src.slice(i, i + 2)
if (two === '//') {
const nl = src.indexOf('\n', i)
const end = nl === -1 ? src.length : nl
inString.fill(1, i, end)
i = end
} else if (two === '/*') {
const close = src.indexOf('*/', i + 2)
const end = close === -1 ? src.length : close + 2
inString.fill(1, i, end)
i = end
} else if (c === '"' || c === "'" || c === '`') {
// The opening quote itself stays unmasked: a specifier is read starting
// at its quote, and the regexp below anchors on that.
i += 1
while (i < src.length && src[i] !== c) {
// A backslash escapes the next character, including the closing quote.
const step = src[i] === '\\' ? 2 : 1
inString.fill(1, i, Math.min(i + step, src.length))
i += step
}
i += 1
} else {
i += 1
}
}
return inString
}
// Static and dynamic imports that survived into the output. A relative or
// absolute specifier is a chunk that was split, which this build does not do —
// `lib` mode with one entry emits one file — so anything here is a bare name.
//
// **This pattern used to require whitespace after `import`, and so could not see
// the one shape the build actually emits.** Minified Rollup output is
// `import{useState}from"react"`, with no space anywhere in it; the old
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
// alternative, met `{` instead of a quote and matched nothing. A bare named
// import — the most likely way for an alias to miss — would have passed this
// check silently. It was found by writing the test for the false POSITIVE above
// it, which is the argument for testing a check against both answers.
//
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
// import's parenthesis.
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
/** Every bare specifier the chunk still imports at runtime. */
export function bareImports(chunk) {
const masked = stringMask(chunk)
const bare = new Set()
for (const match of chunk.matchAll(IMPORTS)) {
// Where the `import` keyword itself starts — one past the leading delimiter,
// unless the match began at position 0.
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
const specifier = match[1]
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
}
return [...bare]
}
// Fingerprints from the shared libraries' own source. Each is a string those
// packages ship and this module has no other reason to contain.
//
// These are matched against the RAW chunk, deliberately unmasked: a bundled
// library's source arrives as code AND as its own error-message strings, and
// masking would discard half the evidence. The direction of the risk is opposite
// to the import check's — here a false positive is a fingerprint too generic,
// which is a fixable choice of probe, not a property of the parse.
const BUNDLED = [
{ what: 'react', probe: 'react.development.js' },
{ what: 'react', probe: 'Invalid hook call' },
{ what: 'react-dom', probe: 'react-dom.development.js' },
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
]
/** Every problem with this chunk, as sentences. Empty means it ships. */
export function problemsWith(chunk) {
const problems = []
const bare = bareImports(chunk)
if (bare.length) {
problems.push(
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')}` +
'nothing can resolve a bare specifier in the browser without an import map, ' +
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
)
}
for (const { what, probe } of BUNDLED) {
if (chunk.includes(probe)) {
problems.push(
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
'There is exactly one React in the page and core owns it — a second copy ' +
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
)
}
}
return problems
}
// Only when run as a script. Importing this from a test must not read a chunk
// that may not have been built, and must not call process.exit.
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (!fs.existsSync(CHUNK)) {
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
process.exit(1)
}
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
if (problems.length) {
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
for (const p of problems) console.error(` - ${p}\n`)
process.exit(1)
}
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
}

260
client/src/api.js Normal file
View File

@@ -0,0 +1,260 @@
// ── This module's own API bindings ────────────────────────────────────────
//
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
// `ApiError` thrown on any non-2xx. The paths are this module's, because the
// routes at the other end are — `server/router/**` in this repo serves them.
//
// **Do not build your own fetch wrapper.** The primitive is what carries the
// session cookie, the CSRF handling and the error shape core's `ErrorState`
// knows how to render. A module that calls `fetch` directly gets none of that
// and finds out one page at a time.
//
// Keeping the bindings in one file, ordered the way the routers are, is
// convention rather than contract — but the two halves of every call live in
// different directories and nothing checks them against each other, so anything
// that makes a mismatch easy to see is worth doing.
import rg from './core.js'
const { request: req, BASE } = rg.api
// ── public ────────────────────────────────────────────────────────────────
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
// `/api/v1/public/rust/servers` — the route `server/router/public/rust.router.js`
// registers under the `/rust` prefix `module.json` declares.
export const servers = {
list: () => req('/public/rust/servers'),
// One server, and the only route under `/servers/:id` that can answer "no such
// server": the four below answer an empty list for an id nobody ever
// configured, because an unknown server genuinely has no events.
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
// both are built here rather than in a page, so the query string this module
// sends exists in one file.
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
kind: kinds && kinds.length ? kinds.join(',') : null,
wipe,
limit,
})}`),
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
// Phase 9. The clan list is public (D58): name, colour, score and member count
// name nobody. `board` says whether the list can be trusted right now.
clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`),
}
// One clan. Its roster comes back only for a viewer inside the operator's roster
// audience (D48) — clan members and staff by default — and `roster.visible`
// says which answer this was, so a page can explain an empty roster rather than
// imply an empty clan.
//
// The id carries colons (`<server>:<clan>:<created>`). They are legal in a path
// segment, and encoded anyway so that a server slug is never read as structure.
export const clans = {
get: (externalId) => req(`/public/rust/clans/${encodeURIComponent(externalId)}`),
}
/**
* A query string from the parameters that have a value, or `''`.
*
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
* string — and a page that sends one because a `<select>` is on "All time" gets
* an empty leaderboard and no error.
*/
function query(params) {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
}
const string = search.toString()
return string ? `?${string}` : ''
}
// ── player ────────────────────────────────────────────────────────────────
// The same list, on the authenticated tier. It exists so that per-player detail
// can be added at an address clients are already calling; today the two answers
// are identical and the server delegates to one model so they cannot drift.
export const playerServers = {
list: () => req('/player/rust/servers'),
}
// R1's identity link, from the signed-in player's side.
//
// **The code is the whole of what goes up.** The site has no idea which server
// minted it — nothing in six characters says — so the server half asks each
// configured server in turn (D24). A page that asked the player to pick would be
// asking them a question the site can answer itself, and a wrong pick would come
// back indistinguishable from a wrong code.
export const playerLinks = {
list: () => req('/player/rust/links'),
confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }),
remove: (steamId) =>
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
}
// What the site has given the caller in game (phase 8). Read-only, and beside
// `playerLinks` rather than under it: an entitlement exists whether or not an
// account is linked yet, which is exactly the state worth showing.
export const playerPermissions = {
list: () => req('/player/rust/permissions'),
}
// ── admin ─────────────────────────────────────────────────────────────────
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
// and a save that omits the field leaves the stored credential alone — so an
// admin form must send it only when the operator typed one, rather than sending
// its own empty field on every save.
export const admin = {
listServers: () => req('/admin/rust/servers'),
saveServer: (id, body) =>
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'PUT', body }),
deleteServer: (id) =>
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
testServer: (id) =>
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
}
// ── admin · permissions (R2) ──────────────────────────────────────────────
//
// The authoring surface. Every call here writes to the SITE, and none of them
// reaches a game server — the mirror's own loop does that on its own cadence.
// `sync` is the exception and says so in its name: it runs the pass now and
// answers with what each server reported, which is the only call on this screen
// that can be slow or fail because a game host is down.
//
// A write is followed by a re-read rather than a local edit of the model: what
// the screen is showing is partly the game's answer, and the honest way to learn
// the new one is to ask.
export const adminPermissions = {
overview: () => req('/admin/rust/permissions'),
catalogue: () => req('/admin/rust/permissions/catalogue'),
saveGroup: (name, body) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
deleteGroup: (name) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
addMember: (name, username) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
method: 'POST',
body: { username },
}),
removeMember: (name, userId) =>
req(
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
{ method: 'DELETE' },
),
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
revoke: (id) =>
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
adoptDrift: (id) =>
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
revokeDrift: (id) =>
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
sync: (serverId = null) =>
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
}
// ── admin · visibility ────────────────────────────────────────────────────
//
// Who may see who is online. The org lead's rule is that nothing names who is
// online by default; this is where an operator deliberately widens it. A save
// answers the whole new state, so the screen re-renders from the server's word
// rather than from what it sent.
export const adminVisibility = {
read: () => req('/admin/rust/visibility'),
save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }),
}
// ── admin · mod configuration (R18) ───────────────────────────────────────
//
// Every call here is a LIVE round trip to a game host, which makes this the only
// section of this file where a call can be slow, or fail because a server is
// off. Nothing is cached anywhere between the browser and the host's disk: a
// cached config is an edit an operator made over SSH that this website then
// silently overwrote.
//
// `save` carries a `version` the host issued with the file. Send a stale one and
// the answer is a 409 with the current file attached, rather than an overwrite
// of whatever somebody else changed in the meantime.
export const adminConfig = {
files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`),
file: (serverId, path) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`),
// Two tiers, one route. `edits` is the generated form — pointers and literals,
// type-preserving — and `text` is the raw document. A number travels as TEXT
// in both: `1.0` parsed into a JavaScript number and sent back as `1` is the
// whole failure this feature was designed around.
save: (serverId, body) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }),
writes: (serverId, limit = null) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
}
// ── the admin.users.detail extension slot ─────────────────────────────────
//
// The client half of R13's first slot. Core hands the component a `userId` and
// NOTHING else — not a client — so an extension builds its own bindings for the
// routes it registered at the other end (§3.5). These two are the only calls in
// this file whose path is core's rather than this module's: the resource is
// core's user, and the module's own segment is the part after it.
export const adminUserLinks = {
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`),
remove: (userId, steamId) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, {
method: 'DELETE',
}),
}
// The same panel's phase 7 half: what this person may do in game. The id in the
// path is the one the slot handed the component, so these send `userId` rather
// than a name — the screen already knows who it is looking at.
export const adminUserPermissions = {
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`),
grant: (userId, body) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, {
method: 'POST',
body,
}),
revoke: (userId, grantId) =>
req(
`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`,
{ method: 'DELETE' },
),
}
// Exported for the rare caller that needs the base itself — an `<img src>`, a
// download link, an EventSource. Reach for `request` first.
export { BASE, query }
export default {
servers,
clans,
playerServers,
playerLinks,
playerPermissions,
admin,
adminPermissions,
adminConfig,
adminVisibility,
adminUserLinks,
adminUserPermissions,
BASE,
}

View File

@@ -0,0 +1,118 @@
// ── The clans on one server ───────────────────────────────────────────────
//
// Rust's OWN clans (R5), best score first. Public at every setting (D58): a
// clan's name, colour, score and member count name nobody. Who is IN a clan is
// the roster, and that lives on the clan's own page behind the operator's
// roster audience (D48).
//
// **The list is only as good as the board it came from**, and the answer says
// how good that is. Three cases would all look like an empty list if rendered
// bare, and they are three different sentences:
//
// • the bridge cannot read this server's clans at all (an older plugin, or a
// Nexus server whose clans live elsewhere) — "unavailable";
// • the game's clan system is switched off — "this server has no clans";
// • it can, and there are none — "nobody has founded one yet".
//
// And a board at the game's 100-clan ceiling (D55) says there may be more.
import { Link } from 'react-router-dom'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { count } from '../lib/format.js'
import api from '../api.js'
export default function Clans({ serverId }) {
const { data, loading, error } = useAsync(() => api.servers.clans(serverId), [serverId])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const clans = (data && data.clans) || []
const board = (data && data.board) || {}
if (clans.length === 0) {
if (!board.supported) {
return (
<Empty
title="Clans are unavailable for this server"
message={board.reason ? `${capitalise(board.reason)}.` : 'The bridge has not reported this servers clans yet.'}
/>
)
}
if (board.enabled === false) {
return <Empty title="This server has no clans" message="Its operator has switched the games clan system off." />
}
return <Empty title="No clans yet" message="Nobody on this server has founded a clan." />
}
return (
<>
{board.truncated && (
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
The game lists at most 100 clans, by score, so there may be more on this server than are shown here.
</p>
)}
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{clans.map((clan, index) => (
<li
key={clan.externalId}
style={{
display: 'flex',
alignItems: 'baseline',
gap: 12,
padding: '10px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', minWidth: 22, textAlign: 'right' }}>
{index + 1}
</span>
<Swatch color={clan.color} />
<Link to={clanPath(clan.externalId)} style={{ fontWeight: 600, flex: 1, minWidth: 0 }}>
{clan.name}
</Link>
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
{count(clan.memberCount)} {clan.memberCount === 1 ? 'member' : 'members'}
</span>
<span className="sans" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap', minWidth: 70, textAlign: 'right' }}>
{count(clan.score)} pts
</span>
</li>
))}
</ul>
</>
)
}
/** Where a clan's page is: the same template the Team provider hands core. */
export function clanPath(externalId) {
return `/rust/clans/${encodeURIComponent(externalId)}`
}
/**
* A clan's colour, as a small square. The server has already checked it is a
* `#rrggbb` — it ends up in a style — and a clan with no colour gets an outline
* rather than a guess.
*/
export function Swatch({ color, size = 12 }) {
return (
<span
aria-hidden="true"
style={{
display: 'inline-block',
flex: 'none',
width: size,
height: size,
borderRadius: 3,
background: color || 'transparent',
border: color ? 'none' : '1px solid var(--line)',
alignSelf: 'center',
}}
/>
)
}
function capitalise(text) {
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text
}

View File

@@ -0,0 +1,26 @@
// ── An empty state with a heading and a sentence ──────────────────────────
//
// Core's `EmptyState` renders its CHILDREN and nothing else. This module passed
// it `title` and `message` from phase 4 onwards — the shape the Integration Kit's
// template teaches — and React drops an unknown prop without a word, so every
// empty panel in the module rendered as a blank box: "Nobody is on", "No scores
// yet", "No servers yet", all of them. Found by the presence fix's browser walk,
// when the "12 players online" it depended on came out as nothing.
//
// Fixed here rather than in core: core's component is shared by every module,
// and a module-side wrapper changes nothing anybody else renders. The client
// suite (`test/uiKitProps.test.js`) refuses a titled EmptyState so the mistake
// cannot come back.
import { EmptyState } from '../core.js'
export default function Empty({ title, message }) {
return (
<EmptyState>
{title && (
<strong style={{ display: 'block', color: 'var(--head)', marginBottom: message ? 6 : 0 }}>{title}</strong>
)}
{message && <span>{message}</span>}
</EmptyState>
)
}

View File

@@ -0,0 +1,157 @@
// ── The feed: what happened on one server ─────────────────────────────────
//
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
// player's report about another player, or the grid square somebody's base is in
// is stored and never answered here — so this component cannot leak one by
// forgetting to filter, which is the point of the boundary living on the server.
//
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
// see the comment at the top of that file for why core's `useAsync` cannot do
// this job.
import { ErrorState, Loading } from '../core.js'
import Empty from './Empty.jsx'
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
import { ago, clock } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
import { hiddenMessage } from './Online.jsx'
const TONE = {
kill: 'var(--accent-bright)',
death: 'var(--muted)',
join: 'var(--mode-live, #5fb98a)',
leave: 'var(--dim)',
chat: 'var(--text)',
server: 'var(--mode-maint, #e6c26a)',
other: 'var(--muted)',
}
export default function Feed({ serverId, wipeId, filter, onFilter }) {
const kinds = kindsFor(filter)
const { data, error, loading, at } = usePolled(
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
// because what is on screen is an answer to a different one; a poll tick
// does not, because it is the same question asked again.
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
)
const events = data ? data.events : []
return (
<div>
<div
className="sans"
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
>
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
Showing{' '}
<select
value={filter}
onChange={(e) => onFilter(e.target.value)}
style={selectStyle}
>
{FILTERS.map((f) => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</label>
{/* What a refresh is FOR: saying when the page last managed one. Without
it a feed that stopped updating looks exactly like a quiet server. */}
{at && (
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
)}
{error && (
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
the last refresh failed showing what we had
</span>
)}
</div>
{loading && <Loading />}
{/* An error with nothing to fall back on is the only case that takes over
the panel. A failed REFRESH keeps the rows and says so in the line
above, because a site whose premise is "it renders while the game is
off" must not blank itself the first time a request does. */}
{error && !data && <ErrorState error={error} />}
{/* Below the operator's presence audience the server withholds every item
that names a player who was on — the killfeed, chat, joins — and keeps
only the server's own story. Said once, above the rows, so a thin feed
reads as withheld rather than as a quiet server. */}
{data && data.presenceHidden && (
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
</p>
)}
{data && events.length === 0 && (
<Empty
title="Nothing here yet"
message={
data.presenceHidden
? 'Nothing this server has reported about itself matches.'
: 'Nothing this server has reported matches. A server that has just been added has no history until it says something.'
}
/>
)}
{events.length > 0 && (
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{events.map((event) => {
const line = describe(event)
return (
<li
key={event.id}
style={{
display: 'flex',
gap: 12,
alignItems: 'baseline',
padding: '7px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<time
className="sans"
dateTime={new Date(event.t).toISOString()}
title={new Date(event.t).toLocaleString()}
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
>
{clock(event.t)}
</time>
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
{line.actor && (line.join || ' ')}
{line.verb}
{line.subject && ' '}
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
{line.detail && (
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
{' · '}
{line.detail}
</span>
)}
</span>
</li>
)
})}
</ol>
)}
</div>
)
}
const selectStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '3px 8px',
fontSize: '0.78rem',
}

View File

@@ -0,0 +1,73 @@
// ── This module's fill for core's `site.footer.status` slot ───────────────
//
// R13, and the contract is MODULE_API.md §3.7. Core owns the position in the
// footer's info row and the separator around it, and passes `linkStyle` so the
// row stays visually one row. **The label, the destination, the data and whether
// anything renders at all are this component's** — that is the whole division,
// and it is why the slot is named for a place rather than for a meaning.
//
// ── The live count, and what it costs ─────────────────────────────────────
//
// The org lead chose a live count ("3 servers · 42 online") over a static link,
// so this fetches. Be clear-eyed about where it fetches from: core renders
// `SiteFooter` inside `PublicLayout`, and every public page renders
// `PublicLayout` ITSELF (§3.3) — so this component mounts once per public page
// view, not once per session. Every public page on the site therefore carries one
// `/public/rust/servers` request, including pages that have nothing to do with
// Rust.
//
// Two things keep that honest rather than merely cheap:
//
// • **It renders NOTHING until it has an answer, and nothing again if the
// request fails.** An unfilled slot renders nothing and core's `wrap` takes
// the separator with it, so a failed fetch degrades to exactly the footer an
// instance with no module installed has. A spinner in a footer would be worse
// than silence on every page of the site.
// • **It never polls.** One request per page view is a cost; a timer in the
// footer of every page would be a different kind of thing entirely.
//
// If that per-page request ever shows up in an operator's logs as a problem, the
// fix is a short-lived module-scope cache here — the decision to keep the number
// live stays intact, and nothing else on the site has to change.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api.js'
export default function FooterStatus({ linkStyle }) {
const [summary, setSummary] = useState(null)
useEffect(() => {
let live = true
api.servers
.list()
.then(({ servers }) => {
if (!live) return
// `online` already accounts for staleness — the model refuses to let a
// row that has not been written in five minutes claim a server is up —
// so this is a sum, not a judgement.
setSummary({
servers: servers.length,
players: servers.reduce((total, server) => total + (server.online ? server.players : 0), 0),
})
})
// Silence, deliberately. This is the footer of every page on the site; a
// module that cannot reach its own API has nothing to say there.
.catch(() => {})
return () => {
live = false
}
}, [])
if (!summary || summary.servers === 0) return null
return (
<Link to="/rust" style={linkStyle}>
{summary.servers === 1 ? '1 server' : `${summary.servers} servers`}
{' · '}
{summary.players === 1 ? '1 online' : `${summary.players} online`}
</Link>
)
}

View File

@@ -0,0 +1,121 @@
// ── The leaderboard ───────────────────────────────────────────────────────
//
// Per wipe when a wipe is selected, all-time when it is not (R12). The two are
// the same rows summed differently rather than two sets of counters, so they can
// never disagree — which is worth knowing here because it means "All time" is
// not a slower or less accurate answer, it is the same table without a WHERE.
//
// It does NOT poll. A leaderboard moves on the scale of a session; a table that
// re-sorted itself under the reader's cursor every twenty seconds would be worse
// than one that is four minutes old, and the page has a `Refresh` on the tab
// strip for anybody who disagrees.
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
// and the column it maps to is this file's. Keeping them in one list is what
// stops a header that sorts by something other than what it says.
const COLUMNS = [
{ key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) },
{ key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) },
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
]
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
const { data, loading, error } = useAsync(
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
[serverId, wipeId, sort],
)
const rows = data ? data.leaderboard : []
// Present on every row or on none — the server decides per request.
const showLastSeen = rows.some((row) => 'lastSeen' in row)
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
if (rows.length === 0) {
return (
<Empty
title="No scores yet"
message={
wipeId
? 'Nobody has done anything countable on this wipe yet.'
: 'This server has not reported anything countable yet.'
}
/>
)
}
return (
<div style={{ overflowX: 'auto' }}>
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
{COLUMNS.map((column) => (
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
{column.sort ? (
<button
type="button"
onClick={() => onSort(column.sort)}
aria-label={`Sort by ${column.label}`}
style={{
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
font: 'inherit',
letterSpacing: 'inherit',
textTransform: 'inherit',
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
}}
>
{column.label}
</button>
) : (
column.label
)}
</th>
))}
{/* The server withholds `lastSeen` below the operator's presence
audience — a gather tally refreshes it every minute somebody plays,
so it would name who is online. The column goes with it rather
than rendering a row of dashes that look like "never". */}
{showLastSeen && (
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
)}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
<td style={cell}>
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
{/* A player this module has never seen NAMED is shown by the tail
of their id rather than as a blank: the row is real, and a
nameless one reads as a rendering fault. */}
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
</td>
{COLUMNS.map((column) => (
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
{column.value(row)}
</td>
))}
{showLastSeen && (
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)
}
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }

View File

@@ -0,0 +1,122 @@
// ── Who is on the server right now ────────────────────────────────────────
//
// Read from the presence BOARD, not counted from connect and disconnect events:
// the bridge re-sends the whole board on every connect and every sixty seconds,
// so this is right even after the website has missed something (PROTOCOL.md
// §8.3). Counting transitions instead would drift, and drift in the direction
// people notice — players who never left.
//
// It polls with the feed, because "who is on" is the one thing on this page that
// is a live question.
import { ErrorState, Loading } from '../core.js'
import Empty from './Empty.jsx'
import { duration, shortId } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
export default function Online({ serverId, online }) {
const { data, error, loading } = usePolled(() => api.servers.online(serverId), {
key: serverId,
intervalMs: 20_000,
})
const players = data ? data.players : []
if (loading) return <Loading />
if (error && !data) return <ErrorState error={error} />
// Nothing names who is online by default (the org lead's rule). Below the
// operator's audience the server answers a count and no names, and the page
// says so — an empty list here would read as "nobody is on", which is a
// different claim and a false one.
if (data && data.hidden) {
const count = Number(data.count) || 0
return (
<Empty
title={online ? `${count.toLocaleString()} ${count === 1 ? 'player' : 'players'} online` : 'The server is offline'}
message={hiddenMessage(data.audience)}
/>
)
}
if (players.length === 0) {
return (
<Empty
title={online ? 'Nobody is on' : 'The server is offline'}
message={
online
? 'The server is up and the island is empty. Somebody has to be first.'
: 'Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.'
}
/>
)
}
return (
<>
{/* A board is the last one that ARRIVED, and an unreachable sidecar does not
clear it — deliberately, because the rows are still the best answer
anybody has. But presented bare they read as "these people are on right
now", which is the one thing an offline server cannot be saying. The
page walk found this with a fixture server whose header said Offline
above three apparently-connected players. */}
{!online && (
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
This server is offline. Below is the last board it sent, not who is on it now.
</p>
)}
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{players.map((player) => (
<li
key={player.steamId}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 12,
padding: '8px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<span>
<strong style={{ color: 'var(--ink)' }}>{player.name || shortId(player.steamId)}</strong>
{/* Sleeping is not idle and not offline — a sleeping player's body is
in the world and can be killed, which is why the board carries the
flag at all. */}
{player.sleeping && (
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}> · sleeping</span>
)}
</span>
{/* `connectedAt` is absent for a player who was already on when the
plugin loaded — an unknown session length, which is not a session of
no length. Saying nothing is the honest render of that. */}
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
{player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''}
</span>
</li>
))}
</ul>
</>
)
}
/** How long a player has been on, from the DATETIME the board reported. */
function sessionSoFar(connectedAt) {
const since = Date.parse(connectedAt)
if (Number.isNaN(since)) return ''
return duration((Date.now() - since) / 1000)
}
/**
* Why something was withheld, in words a visitor can act on.
*
* `what` completes the sentence — "who they are", "what players did". The
* audience is the operator's (`staff` unless widened), and only `signed_in` is
* something a visitor can do anything about.
*/
export function hiddenMessage(audience, what = 'who they are') {
if (audience === 'signed_in') return `Sign in to see ${what}.`
if (audience === 'public') return `This site is not showing ${what} right now.`
return `Only this sites staff can see ${what}.`
}

View File

@@ -0,0 +1,63 @@
// ── Tabs, bundled rather than borrowed ────────────────────────────────────
//
// The shared kit is nine members and it is CLOSED (MODULE_API.md §3.4): layout,
// headings, the three data-page states, the fetch hook, the session, the site
// and `Slot`. A tab strip is not in it, so it is here — which is the kit working
// as designed rather than a gap in it. What the kit guarantees is that a module
// page looks like the site while it loads and while it fails; everything a page
// builds on top of that is the module's own.
//
// It is styled with core's CSS VARIABLES and its `.pill` class rather than with
// colours of its own, so it re-themes with the instance (THEMING_AND_NAV.md).
// The one class this module must never write by hand is the shell wrapper —
// `PublicLayout`'s `shell` prop exists precisely so that one stays core's.
//
// **The selected tab lives in the URL, not in this component.** A tab strip that
// owned its own state would make every panel on this page unlinkable: "look at
// the leaderboard for this server" would be a sentence rather than a link, back
// would leave the page entirely, and a refresh would land on the first tab. So
// this is a controlled component and `ServerDetail` keeps the state in a search
// parameter.
export default function Tabs({ tabs, active, onSelect, label = 'Sections' }) {
return (
<div
role="tablist"
aria-label={label}
className="sans"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
borderBottom: '1px solid var(--line)',
paddingBottom: 12,
marginBottom: 20,
}}
>
{tabs.map((tab) => {
const selected = tab.id === active
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onSelect(tab.id)}
style={{
cursor: 'pointer',
padding: '6px 14px',
borderRadius: 'var(--radius-pill, 999px)',
fontSize: '0.82rem',
letterSpacing: '0.04em',
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
background: selected ? 'var(--blue)' : 'transparent',
color: selected ? 'var(--accent-bright)' : 'var(--muted)',
}}
>
{tab.label}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,54 @@
// ── "This wipe" or "All time" ─────────────────────────────────────────────
//
// One control, used by two panels, because the wipe is a property of the PAGE
// rather than of the feed or the leaderboard — a reader who has chosen last
// month's map means it for both, and two selects that could disagree is a page
// that shows one wipe's kills next to another's leaderboard.
//
// It loads the wipe list itself. That is a second request for the same list the
// Wipes tab fetches, and it is the right trade: the alternative is the page
// fetching it on mount for a control most visitors never touch, on every visit,
// for every server.
import { useAsync } from '../core.js'
import { day } from '../lib/format.js'
import api from '../api.js'
/** The value that means "no wipe filter at all". Never the empty string — see `api.js`'s `query`. */
export const ALL_TIME = 'all'
export default function WipeSelect({ serverId, value, onChange, currentWipeId }) {
const { data } = useAsync(() => api.servers.wipes(serverId), [serverId])
const wipes = data ? data.wipes : []
// A server with one wipe has nothing to choose between, so the control is not
// offered. "All time" and "this wipe" are the same answer there, and a select
// with one real option is furniture that invites a question with no answer.
if (wipes.length < 2) return null
return (
<label className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
Wipe{' '}
<select
value={value || ALL_TIME}
onChange={(event) => onChange(event.target.value)}
style={{
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '3px 8px',
fontSize: '0.78rem',
}}
>
<option value={ALL_TIME}>All time</option>
{wipes.map((wipe) => (
<option key={wipe.wipeId} value={wipe.wipeId}>
{day(wipe.saveCreatedAt || wipe.firstSeen)}
{wipe.wipeId === currentWipeId ? ' (current)' : ''}
</option>
))}
</select>
</label>
)
}

View File

@@ -0,0 +1,86 @@
// ── Every wipe this server has had ────────────────────────────────────────
//
// The list is what makes the rest of the page navigable — picking a wipe here
// filters the feed and the leaderboard — and it is also the proof R12 asks for:
// a wipe that ended is still here, with its record still attached. A Rust server
// wipes monthly, and a community site that forgot the previous map every time
// would throw away most of what it knows about its own players.
//
// `wipeId` is derived by the bridge PLUGIN from the save's creation time and
// stamped on every frame (PROTOCOL.md §8.2), so the id in this list is the same
// id the events and the leaderboard filter by. There is no second derivation
// anywhere that could disagree.
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, day } from '../lib/format.js'
import api from '../api.js'
export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
const { data, loading, error } = useAsync(() => api.servers.wipes(serverId), [serverId])
const wipes = data ? data.wipes : []
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
if (wipes.length === 0) {
return (
<Empty
title="No wipes recorded"
message="A wipe appears here once this server has reported something during it."
/>
)
}
return (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{wipes.map((wipe) => {
const current = wipe.wipeId === currentWipeId
const active = wipe.wipeId === selected
return (
<li key={wipe.wipeId} style={{ borderBottom: '1px solid var(--line-soft, var(--line))' }}>
<button
type="button"
onClick={() => onSelect(wipe.wipeId)}
style={{
display: 'flex',
width: '100%',
gap: 12,
alignItems: 'baseline',
justifyContent: 'space-between',
padding: '10px 6px',
cursor: 'pointer',
background: active ? 'var(--blue)' : 'transparent',
border: 'none',
color: 'inherit',
font: 'inherit',
textAlign: 'left',
}}
>
<span>
<strong style={{ color: 'var(--ink)' }}>
{/* The save's creation time is the wipe's own date; `firstSeen`
is when THIS website first heard about it, and they differ
by however long the module was not installed. The first is
the wipe, so it leads. */}
{day(wipe.saveCreatedAt || wipe.firstSeen)}
</strong>
{current && (
<span className="sans" style={{ color: 'var(--mode-live, #5fb98a)', fontSize: '0.74rem' }}>
{' · current'}
</span>
)}
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem' }}>
{wipe.wipeId}
</span>
</span>
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
last heard {ago(wipe.lastSeen)}
</span>
</button>
</li>
)
})}
</ul>
)
}

85
client/src/core.js Normal file
View File

@@ -0,0 +1,85 @@
// ── What core hands this module, on the client side ────────────────────────
//
// The client twin of `server/core.js`, and deliberately much simpler than it.
// Every page imports its layout, its state components and its hooks from here,
// so the boundary is one file. The normative contract is MODULE_API.md §3.2 and
// §3.4.
//
// **Why this is a plain read and the server's is a lazy accessor.** On the
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
// run — so `server/core.js` has to defer resolution to call time or a router
// would capture `undefined` at file scope. There is no such gap here.
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
// and every module chunk is a deferred script the server injects *after* that
// bundle's tag, so by the time the first line of this file executes the global
// is already there. Reading it once, at module scope, is safe — and it means a
// component keeps the ordinary `import { PageHeader } from '…'` shape rather
// than being wrapped in an accessor that would cost it its identity.
//
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
// also goes through — the shims touch the global before this file does, so a
// check here would be unreachable.
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { Link } from 'react-router-dom'
import { rg as shared } from './shim/rg.js'
const rg = shared()
// ── The shared-dependency self-check ───────────────────────────────────────
//
// Keep this. There are two BUILD guards on the same rule — `assertSharedNotBundled`
// in vite.config.js at resolution time, and `scripts/checkExternals.js` on the
// finished artifact — and both reason about the chunk in isolation. Neither can
// see the one failure that only exists once the chunk meets a core: a
// `window.__rg` whose React is not the React that rendered the page.
//
// Identity is the only question worth asking. A second React satisfies every
// type check, renders its first element happily, and then throws about an invalid
// hook call somewhere unrelated — in a component that has nothing to do with it.
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
console.error(
'[rust] the bindings this chunk imported are not the ones core published — it has bundled ' +
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
)
}
// The curated kit (§3.4). Nine exports, and it is CLOSED: layout, headings, the
// three data-page states, the fetch hook, read-only access to the session and the
// site's settings, and `Slot`. Anything else your pages need — tables, tabs, an
// editor — you bundle yourself, in a `components/` directory of your own.
//
// `Slot` is the one that is not a widget. It renders a place THIS module declared
// for core to fill (`entry.jsx`, and `routes/public/Clan.jsx` where two are used):
// the inverted direction of the extension-slot mechanism, added in 1.6.0. It is in
// the shared kit rather than reimplementable for the reason the whole kit exists —
// a second error boundary with different behaviour would be a second bug, and what
// this one contains is CORE's content failing inside YOUR page.
//
// Closed is a real constraint and it is the price of the boundary being worth
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing an
// existing prop on a kit component is a major one. Use them, though. A module page that
// ships its own layout is a page that stops looking like the site it is installed
// in, and drifts further every time core changes.
export const {
PublicLayout,
PageHeader,
Loading,
ErrorState,
EmptyState,
useAsync,
useAuth,
useSite,
Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.
export const registry = rg.registry
// The core API version this module was loaded against. Logged by entry.jsx —
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check, only something to report.
export const coreApiVersion = rg.version
export default rg

204
client/src/entry.jsx Normal file
View File

@@ -0,0 +1,204 @@
// ── The client entry point ────────────────────────────────────────────────
//
// Core serves `dist/entry.js` from this module's directory and injects it into
// its own HTML as a same-origin `<script type="module" src>` before `</body>`.
// This file registers what the module has; core renders it. Normative:
// MODULE_API.md §3.3.
//
// **Registration is synchronous and happens at evaluation time.** Module scripts
// are deferred, so this runs after core's bundle — which is where `window.__rg`
// is published — and before core's first render. There is no subscription and no
// late registration: a module that registered asynchronously would register after
// the route table had been read, and the symptom is a page that redirects home
// with nothing logged anywhere.
//
// So everything below is a plain top-level call and every page is a STATIC
// import. Lazy-loading the routes is the natural instinct for a chunk that grows,
// and it is the one thing this seam cannot have.
import { registry, coreApiVersion } from './core.js'
import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
import Clan from './routes/public/Clan.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
import Visibility from './routes/admin/Visibility.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconEye, IconKey, IconLink, IconSliders } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
const ID = 'rust'
// ── Routes ────────────────────────────────────────────────────────────────
//
// Paths are relative to the module's namespace and core prefixes them. Whatever
// is written here, a public route lands at `/<id>/<path>`, an admin route at
// `/admin/<id>/<path>` and a player route at `/player/<id>/<path>`. A module
// cannot write the segment its routes hang under, which is the point: two modules
// installed side by side cannot collide, and an operator can see from a URL which
// module served it.
//
// So the list below is at `/rust` and the detail page at `/rust/servers/:id`.
//
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
// available and core applies it as its own `RoleGate`; supplying your own is not
// possible, because the sidebar and the route table have to agree about who may
// see what, and they only do if one thing decides.
//
// R8's landing page is the server list, and `/rust/servers/:id` hangs beneath it.
//
// **The list is registered with an EMPTY path**, which core renders as the
// module's namespace root: `/rust`. The prefixing code strips the separator it
// would otherwise leave behind (`registry.js`: `${id}/${path}` with trailing
// slashes trimmed), so a module can own its own root without being able to spell
// its way out of it. Phase 1 served this page at `/rust/servers` and left `/rust`
// to core's CMS catch-all; the org lead settled it at `/rust` in phase 4, so the
// address an operator links to is the module's name.
//
// React Router ranks a static segment above a dynamic one, so `/rust` wins
// against core's `/:slug` CMS route without depending on registration order.
//
// The player route is registered with an empty path for the same reason the
// public list is: `/player/rust` is the whole of what this module asks a player
// to do, and a landing page above one page is a page nobody wants. Core applies
// its own portal chrome and its own auth gate to the tier, so the component
// renders no layout and re-implements no check.
//
// **The admin route arrives in phase 7 and is this module's first.** Everything
// before it was configured through the API — the server rows still are — because
// nothing until now had to be AUTHORED. A permission model is different in kind:
// it is a thing an operator composes and keeps looking at, and there is no
// version of "grant somebody VIP" that belongs in a terminal.
//
// It is registered with an empty path, so it lands at `/admin/rust`, and core
// applies the admin tier's own gate. The routes underneath it are stricter than
// that gate (`requireRole('admin')` on every one), which is a server-side answer
// rather than a client one: a moderator who reached this page would see it fail
// honestly rather than be quietly shown a page that cannot save.
registry.registerRoutes(ID, {
public: [
{ path: '', element: <Servers /> },
{ path: 'servers/:id', element: <ServerDetail /> },
// Phase 9 (D56). Not nested under its server: core links here from Team
// notification email through `pageUrlTemplate`, which substitutes
// `{externalId}` and nothing else — and the server is inside that id.
{ path: 'clans/:externalId', element: <Clan /> },
],
player: [{ path: '', element: <Account /> }],
admin: [
{ path: '', element: <Permissions /> },
// Phase 7b (R18). A second admin page rather than a tab on the first: the
// permission mirror decides who may do what inside the game, and this edits
// the game host's own files. They are neighbours, not halves of one screen,
// and the nav says so with two rows.
//
// A static segment under the module's namespace, so it lands at
// `/admin/rust/config` and core's admin gate applies to it exactly as it
// does to the page above.
{ path: 'config', element: <ModConfig /> },
// Who may see who is online — a third neighbour. The org lead's rule is that
// nothing names who is online by default; this is where an operator widens
// it on purpose, fleet-wide or per server.
{ path: 'visibility', element: <Visibility /> },
],
})
// ── Nav ───────────────────────────────────────────────────────────────────
//
// A registered row is an ORDINARY row from here on. It interleaves into core's
// own navigation, and an operator can reorder it, relabel it or hide it from the
// admin nav editor exactly as they can core's — because the interleave happens
// before the override merge, and the override layer is keyed by `to`.
//
// Three fields worth knowing before you need them:
//
// • `order` places the row among core's, which are keyed by their index. A row
// with NO order appends after them, rather than defaulting to 0 — otherwise
// "I didn't ask for a position" would mean "put me first".
// • `group` (admin sidebar) names an existing core group; an unknown name
// appends a new group at the end rather than dropping the row.
// • `icon` is a component, and core supplies no fallback. Public header rows
// carry no icons, so there is none here — but an admin or player row without
// one is the only row in its sidebar with no glyph, which reads as breakage.
registry.registerNav(ID, {
area: 'public',
items: [{ label: 'Servers', to: '/rust' }],
})
// The player portal's row. It carries an `icon` because core draws one on every
// portal row — a row without one is the only text in a column of glyphs, and
// core used to render `<n.icon />` unguarded, which blanked the whole portal.
//
// No `order`: an unordered row appends after core's own rather than claiming a
// position it was not given. Account, appeals and notifications are what a player
// came to the portal for; linking a game account is what they do once.
registry.registerNav(ID, {
area: 'player',
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
})
// The admin sidebar's row. `group` names an existing core group — an unknown name
// appends a new group at the end rather than dropping the row, which is the
// failure mode to avoid here: a row nobody can find is a feature nobody has.
//
// It carries an icon for the same reason the player row does: core draws one on
// every sidebar row, and the one without is the only text in a column of glyphs.
registry.registerNav(ID, {
area: 'admin',
items: [
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
],
})
// ── Extension slots ───────────────────────────────────────────────────────
//
// Core declares a slot, only core may declare one, and at most one module may
// fill it (§3.7). `site.footer.status` is the status-ish spot in core's footer
// info row: core owns the position and passes `linkStyle`; the label, the
// destination, the data and whether anything renders at all are the module's.
//
// It is a CLIENT slot and cannot be named in `module.json`'s `extensions` —
// that array is validated against the SERVER registry, and naming a client slot
// there fails the load outright with `unknown extension slot`. Phase 1 found
// that the hard way; the two halves of R13 are declared in different places on
// purpose.
registry.registerExtension(ID, 'site.footer.status', FooterStatus)
// R13's other slot, and the one that IS named in `module.json` — because it has
// a server half too (`server/router/admin/usersRust.router.js`). The two halves
// carry one name on purpose: a module that adds routes under
// `/api/v1/admin/users/:id` is the module with something to show on that page.
//
// Core passes `userId` and nothing else, so the component builds its own client
// for the routes the server half registered. It renders NOTHING for a user with
// no linked Steam account, which is most of them.
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
// ── Inverted slots: core's Team contributions on OUR clan page ─────────────
//
// §3.7a. A clan is a Team (R5), and core renders no Team page because it does
// not own the word "clan". So the page is `routes/public/Clan.jsx` and core
// contributes the three things only it can render — into places this module
// names, in this module's vocabulary. Core offers a CONTRIBUTION; it never names
// a slot, which is what lets a second game use the same contract as module-uo.
//
// One slot per PLACE (D56): a slot holds one component, and a collapsed slot
// would hand core the decision about where each part sits on a page it does not
// own. Asking for a contribution core does not offer throws here, at
// registration — a typo fails loudly rather than rendering nothing for ever.
registry.declareModuleSlot(ID, 'rust.clan.header', { core: 'team.notify' })
registry.declareModuleSlot(ID, 'rust.clan.detail', { core: 'team.activity' })
registry.declareModuleSlot(ID, 'rust.clan.forum', { core: 'team.forum' })
// `module.json`'s `coreApi` range was checked by the loader before this file was
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
// between the core that validated the manifest and the core that published this
// global is otherwise invisible from the browser, which is where the client half
// actually fails.
console.info(`[${ID}] registered against core API ${coreApiVersion}`)

View File

@@ -0,0 +1,116 @@
// ── A poll that keeps what it already had ─────────────────────────────────
//
// **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and
// `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})`
// re-run on a dependency change — and the first thing it does on every run is
// blank `data` and set `loading`. That is right for a page load and wrong for a
// poll: bumping a dependency every twenty seconds would clear the killfeed,
// render `<Loading />` in its place and re-fill it, four times a minute, for ever.
//
// So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps
// the previous rows on screen, replaces them when the new ones arrive, and keeps
// them *and* reports the error when the fetch fails — because a site whose whole
// premise is "it renders while the game is off" must not blank the page the
// first time a request does.
//
// `useAsync` is still the right hook for everything that loads once, and the
// pages here use it for exactly that. Bundling this beside it is the kit working
// as intended: the nine shared members are the chrome every module must share,
// not a ceiling on what a module may write.
//
// ── Two behaviours worth knowing ──────────────────────────────────────────
//
// 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate
// refresh when the viewer comes back — which is also the moment stale rows
// are most visible. A tab left open overnight is otherwise a request every
// twenty seconds until the laptop dies.
// 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD
// blank the rows: what is on screen belongs to a different question. That is
// what `key` is for, and it is separate from the interval.
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* @param {() => Promise<any>} fetcher called with no arguments; must not throw synchronously
* @param {object} options
* @param {string} options.key changes when the QUESTION changes, blanking the answer
* @param {number} options.intervalMs 0 disables polling — the hook then loads once
* @param {boolean} options.enabled false while the page has nothing to ask about yet
*/
export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) {
const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null })
// The fetcher is rebuilt on every render — it closes over props — and a hook
// that listed it as a dependency would restart its interval every render. The
// ref is how the timer keeps calling the CURRENT one without depending on it.
const latest = useRef(fetcher)
latest.current = fetcher
// Guards a reply from a question nobody is asking any more: a slow request
// whose page has moved on, or one still in flight at unmount.
const generation = useRef(0)
const run = useCallback(
async (mine) => {
try {
const data = await latest.current()
if (mine !== generation.current) return
setState({ data, error: null, loading: false, at: Date.now() })
} catch (error) {
if (mine !== generation.current) return
// `data` is carried forward deliberately. A failed refresh is a page that
// says "this is what we last knew, and it did not refresh", which is the
// same promise the server list makes about a game server being down.
setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at }))
}
},
[],
)
const refresh = useCallback(() => run(generation.current), [run])
useEffect(() => {
generation.current += 1
const mine = generation.current
if (!enabled) {
setState({ data: null, error: null, loading: false, at: null })
return undefined
}
setState({ data: null, error: null, loading: true, at: null })
run(mine)
if (!intervalMs) return () => { generation.current += 1 }
let timer = null
const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible'
const start = () => {
if (timer === null) timer = setInterval(() => run(mine), intervalMs)
}
const stop = () => {
if (timer !== null) { clearInterval(timer); timer = null }
}
const onVisibility = () => {
if (visible()) { run(mine); start() } else stop()
}
if (visible()) start()
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
return () => {
// Bumping the generation on teardown is what makes an in-flight reply from
// the old question land nowhere. Clearing the timer alone would not.
generation.current += 1
stop()
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
}
}, [key, intervalMs, enabled, run])
return { ...state, refresh }
}
export default usePolled

98
client/src/icons.jsx Normal file
View File

@@ -0,0 +1,98 @@
// ── The nav glyph for this module's player-portal row ─────────────────────
//
// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core
// renders whatever component a row carries, exactly as it renders its own rows'
// icons — and core's player portal draws a glyph on every row, so a row without
// one reads as breakage rather than as a design. The client suite asserts it.
//
// The public header is text buttons and carries no icons, which is why this file
// arrives with the player row and not before it.
//
// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by
// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes
// of presentation are not a component: putting them in the shared kit would
// freeze core's icon sizing into the contract, where changing it later would be a
// major bump. A module that wants to look like the nav it is in matches that nav.
const Icon = ({ children }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
)
/**
* A chain link — what the row is for.
*
* Not a gem, a person or a server: the portal's rows say what a player does
* there, and what a player does at `/player/rust` is link an account. Core's own
* neighbours are a gear (account), a shield (appeals) and a bell (notifications),
* so the row has to read as a verb in that company.
*/
export const IconLink = () => (
<Icon>
<path d="M10 13a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11.5 4.5" />
<path d="M14 11a5 5 0 00-7.07 0L4.1 13.83a5 5 0 007.07 7.07L12.5 19.5" />
</Icon>
)
/**
* A key — the admin sidebar's row for the permission mirror.
*
* Core's admin groups are labelled by subject and drawn with glyphs of the same
* weight, so this is the same 16px frame as the portal's. A key rather than a
* shield: a shield is protection from something, and this row is about handing
* somebody the right to do something.
*/
export const IconKey = () => (
<Icon>
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3L20 3" />
<path d="M17 6l2.5 2.5" />
</Icon>
)
/**
* Sliders — the admin sidebar's row for the mod-configuration editor.
*
* Not a gear: core's account row is a gear, and two gears in one sidebar say
* "settings" twice without saying whose. Sliders read as values being tuned,
* which is exactly what that page does to somebody else's game host.
*/
export const IconSliders = () => (
<Icon>
<path d="M4 6h10" />
<path d="M18 6h2" />
<circle cx="16" cy="6" r="2" />
<path d="M4 12h4" />
<path d="M12 12h8" />
<circle cx="10" cy="12" r="2" />
<path d="M4 18h10" />
<path d="M18 18h2" />
<circle cx="16" cy="18" r="2" />
</Icon>
)
/**
* An eye — the admin sidebar's row for who may see who is online.
*
* The page decides what the public can SEE, so the glyph is the act of seeing.
*/
export const IconEye = () => (
<Icon>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
</Icon>
)
export default { IconLink, IconKey, IconSliders, IconEye }

178
client/src/lib/feed.js Normal file
View File

@@ -0,0 +1,178 @@
// ── One stored frame as one line of a feed ────────────────────────────────
//
// `GET /public/rust/servers/:id/events` answers rows shaped
// `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame
// the plugin emitted — this module stores what it is given and indexes only the
// columns it serves (PROTOCOL.md §8.4, and the `raw` column in schema.sql). So
// everything a killfeed line needs is in `frame`, under the names the plugin
// wrote, and this file is the one place that knows them.
//
// **It returns PARTS, not a sentence.** A component wants the names emphasised
// and the detail muted, and a function returning `"Alice killed Bob"` forces
// either a `dangerouslySetInnerHTML` or a re-parse. Parts also make this
// testable without a DOM, which is the whole reason it is not a component.
//
// ── The rule for an unknown kind ──────────────────────────────────────────
//
// It renders as itself. A later protocol adds kinds, an operator's module may be
// older than their game host, and a feed that DROPPED what it did not recognise
// would be a page that quietly says less than the truth. The server's allowlist
// has already decided this row may be seen (`server/catalogue.js`); what is left
// here is presentation, and the honest presentation of a kind we have no words
// for is its own name.
import { duration, prefab } from './format.js'
/**
* Kinds this feed asks for.
*
* `player.tally` is public and deliberately NOT here: it is an aggregate the
* plugin flushes every sixty seconds per active player (§8.6), so a feed
* including it would be mostly wood counts. It is the leaderboard's input, and
* the leaderboard is where it shows up.
*/
export const FEED_KINDS = Object.freeze([
'player.death',
'player.connected',
'player.disconnected',
'player.respawned',
'player.chat',
'server.wipe',
'server.initialized',
'server.shutdown',
])
/** The filters the feed offers, and the kinds each one asks the API for. */
export const FILTERS = Object.freeze([
{ id: 'all', label: 'Everything', kinds: FEED_KINDS },
{ id: 'kills', label: 'Kills', kinds: ['player.death'] },
{ id: 'chat', label: 'Chat', kinds: ['player.chat'] },
{
id: 'sessions',
label: 'Comings and goings',
kinds: ['player.connected', 'player.disconnected', 'player.respawned'],
},
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
])
export function kindsFor(filterId) {
const filter = FILTERS.find((f) => f.id === filterId)
return (filter || FILTERS[0]).kinds
}
/**
* One row as `{ tone, actor, join, verb, subject, detail }`.
*
* `actor` and `subject` are names and are emphasised; `verb` and `detail` are
* prose. Any of them may be empty. `tone` is the row's category, for the small
* colour the component gives it — never for deciding what a row means.
*
* `join` is what goes between the actor and the verb, and it exists for exactly
* one case: chat. "Brannock see you in september" is not a sentence anybody
* writes, and putting the colon in the message would put presentation inside the
* text a player typed.
*/
export function describe(row) {
const frame = (row && row.frame) || {}
const name = frame.name || null
switch (row && row.kind) {
case 'player.death':
return death(frame, name)
case 'player.connected':
return { tone: 'join', actor: name, verb: 'connected', subject: null, detail: '' }
case 'player.disconnected':
return {
tone: 'leave',
actor: name,
verb: 'disconnected',
subject: null,
// Two optional halves, and the session is the interesting one: the plugin
// omits `sessionSec` for a player who was already on when it loaded, so an
// absent value means "unknown", never zero (§8.4's note, and OnPlayerDisconnected).
detail: [frame.reason || null, frame.sessionSec ? `after ${duration(frame.sessionSec)}` : null]
.filter(Boolean)
.join(' · '),
}
case 'player.respawned':
return { tone: 'join', actor: name, verb: 'respawned', subject: null, detail: '' }
case 'player.chat':
return {
tone: 'chat',
actor: name,
join: ': ',
// The message is the row, so it goes in `verb` where a component renders
// it unemphasised — and it is the one field on this wire a player chooses
// the bytes of. React escapes it; nothing here may ever stop doing that.
verb: frame.message || '',
subject: null,
detail: frame.channel && frame.channel !== 'Global' ? frame.channel : '',
}
case 'server.wipe':
return {
tone: 'server',
actor: null,
verb: 'The map was wiped',
subject: null,
detail: frame.wipeId ? `new wipe ${frame.wipeId}` : '',
}
case 'server.initialized':
return { tone: 'server', actor: null, verb: 'The server came up', subject: null, detail: '' }
case 'server.shutdown':
return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' }
default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
}
}
/**
* A death, which is four different sentences.
*
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
* that a reader does not have to guess from an absent field, and collapsing any
* two of them loses something (see `DescribeAttacker` in the bridge plugin). A
* killfeed that reported a fall as a kill by nobody is the failure this avoids.
*/
function death(frame, name) {
const where = [
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
frame.distance ? `${Math.round(frame.distance)}m` : null,
frame.grid || null,
frame.sleeping ? 'while sleeping' : null,
]
.filter(Boolean)
.join(' · ')
switch (frame.attackerType) {
case 'player':
return { tone: 'kill', actor: frame.attackerName || null, verb: 'killed', subject: name, detail: where }
case 'self':
return { tone: 'death', actor: name, verb: 'died by their own hand', subject: null, detail: where }
case 'npc':
return {
tone: 'death',
actor: prefab(frame.attackerName) || 'Something',
verb: 'killed',
subject: name,
detail: where,
}
// `environment` and anything else: falling, drowning, the world. `HitInfo`
// is legitimately null on this path, so an absent attacker type is this case
// rather than a missing field to complain about.
default:
return { tone: 'death', actor: name, verb: 'died', subject: null, detail: where }
}
}
export default { describe, FEED_KINDS, FILTERS, kindsFor }

136
client/src/lib/format.js Normal file
View File

@@ -0,0 +1,136 @@
// ── Formatting, with no dependencies and no React ─────────────────────────
//
// Every function here is pure and takes what the API answered, so the suite next
// door can ask all of it without a DOM. That is deliberate: the client half's
// real failures are timing and resolution (see `test/build.test.js`), which a
// DOM-less runner cannot see — so the way to have any test coverage at all on
// this side is to keep the parts that CAN be tested free of React.
//
// `Intl` does the work. It is in every browser core supports, it knows the
// viewer's locale and their clock, and it is one fewer thing in a chunk an
// operator ships.
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
const UNITS = [
['year', 31536000],
['month', 2592000],
['week', 604800],
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1],
]
/**
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
*
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
* the model produced, and an event's `t` is the millisecond stamp the plugin put
* on the frame. Accepting both here is what stops every caller remembering which
* is which.
*/
export function ago(value, now = Date.now()) {
const at = toMillis(value)
if (at === null) return 'never'
const seconds = Math.round((at - now) / 1000)
const magnitude = Math.abs(seconds)
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
// not what anybody means. Say the thing.
if (magnitude < 45) return 'just now'
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
return RELATIVE.format(Math.round(seconds / size), unit)
}
/**
* The stamp on a feed row.
*
* **Today's rows get a time; everything older gets a date as well.** The feed can
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
* reads as this afternoon — which the page walk found the moment it looked at the
* previous wipe: three events from August, all apparently a few minutes old.
*
* `now` is a parameter so the boundary is testable rather than a property of the
* machine the test runs on.
*/
export function clock(value, now = Date.now()) {
const at = toMillis(value)
if (at === null) return ''
const when = new Date(at)
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const today = new Date(now)
const sameDay =
when.getFullYear() === today.getFullYear() &&
when.getMonth() === today.getMonth() &&
when.getDate() === today.getDate()
if (sameDay) return time
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
}
/** A date, for a wipe: the thing people actually compare wipes by. */
export function day(value) {
const at = toMillis(value)
if (at === null) return 'unknown'
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* A session or a playtime, as `4h 12m`.
*
* Seconds are dropped above a minute and kept below it, because a two-hour
* session reported to the second is noise and a forty-second one reported as
* "0m" is wrong.
*/
export function duration(seconds) {
const total = Number(seconds)
if (!Number.isFinite(total) || total <= 0) return '—'
if (total < 60) return `${Math.round(total)}s`
const hours = Math.floor(total / 3600)
const minutes = Math.round((total % 3600) / 60)
if (hours === 0) return `${minutes}m`
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
}
/** Thousands separators, in the viewer's locale. */
export function count(value) {
const n = Number(value)
return Number.isFinite(n) ? n.toLocaleString() : '0'
}
/**
* A prefab short name as something readable — `patrolhelicopter` stays itself,
* `rifle.ak` becomes `rifle ak`.
*
* Deliberately a light touch rather than a lookup table. A table mapping every
* Rust prefab to a pretty name is a second copy of the game's item list that
* goes stale every wipe, and the short name is what a Rust player reads on their
* own server console anyway.
*/
export function prefab(name) {
if (!name) return ''
return String(name).replace(/[_.]+/g, ' ').trim()
}
/** A steam id, shortened for a table cell, without pretending it is a name. */
export function shortId(steamId) {
const id = String(steamId || '')
return id.length > 10 ? `${id.slice(-6)}` : id
}
function toMillis(value) {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') return Number.isFinite(value) ? value : null
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? null : parsed
}
export default { ago, clock, day, duration, count, prefab, shortId }

View File

@@ -0,0 +1,554 @@
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
//
// R18. An admin picks a server, a plugin and a file, changes something, and the
// plugin reloads. This module's second admin page, and the first that writes to
// somebody's filesystem.
//
// **What is on the screen is decided by what is dangerous about the action.**
// Four things are true here that are not true anywhere else in this module, and
// each of them is a piece of the page rather than a line in a doc:
//
// • a save can take a required plugin DOWN. So the reload target is a
// deliberate choice with the folder name as a guess, the result is reported
// as its own panel, and a rollback shows the server's own log line.
// • the form cannot express everything a config holds. A `null`, an empty
// array and anything past the depth limit are marked and sent to the raw
// tier rather than half-drawn.
// • three keys in the bridge's own config would cut the link carrying the
// edit, or split the server's history. They render read-only, with the
// reason (D38).
// • configs hold API keys and Discord webhooks. Those fields render masked
// with a reveal, which is about the shoulder rather than the wire: an admin
// can already read the file over SSH (D37), and the audit trail never
// records the values either way.
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
function Card({ title, subtitle, children, actions }) {
return (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
<span style={{ flex: 1 }} />
{actions}
</header>
{children}
</section>
)
}
function Warn({ children, tone = '#d08a2a' }) {
return (
<p className="sans" style={{ color: tone, fontSize: '0.78rem', margin: '6px 0 0' }}>
{children}
</p>
)
}
/** A value the form can edit: one row, typed by what the file already holds. */
function Field({ field, value, onChange, revealed, onReveal }) {
const indent = 12 * Math.max(0, field.depth - 1)
const label = (
<label
className="sans"
style={{
flex: '0 0 300px',
paddingLeft: indent,
color: field.locked ? 'var(--ink)' : 'var(--head)',
fontSize: '0.84rem',
wordBreak: 'break-word',
}}
title={field.path}
>
{field.key}
{field.locked && (
<span className="dim" style={{ fontSize: '0.72rem' }}>
{' '}
· read-only
</span>
)}
</label>
)
if (field.type === 'object' || field.type === 'array') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0 2px' }}>
<span
className="sans"
style={{ paddingLeft: indent, color: 'var(--head)', fontSize: '0.86rem', fontWeight: 500 }}
>
{field.key || '(the file)'}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{field.type === 'array' ? `${field.count} entries` : `${field.count} settings`}
{field.advanced && field.reason ? ` · ${field.reason}` : ''}
</span>
</div>
)
}
if (field.advanced) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
<span className="sans dim" style={{ fontSize: '0.78rem' }}>
{field.reason} edit it in Raw JSON
</span>
</div>
)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
{label}
{field.type === 'boolean' ? (
<input
type="checkbox"
checked={Boolean(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.checked)}
/>
) : (
<input
className="input"
style={{ flex: 1, minWidth: 0 }}
type={field.secret && !revealed ? 'password' : 'text'}
value={value === undefined || value === null ? '' : String(value)}
disabled={field.locked}
onChange={(event) => onChange(field, event.target.value)}
/>
)}
{field.secret && !field.locked && (
<button type="button" className="btn btn-ghost" onClick={() => onReveal(field.path)}>
{revealed ? 'Hide' : 'Show'}
</button>
)}
</div>
)
}
/** What the game said happened. The rollback case is the one worth reading. */
function Report({ report }) {
if (!report) return null
const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)'
return (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
{report.rolledBack
? 'The plugin did not come back, so the old file was put back automatically.'
: report.reloaded
? 'Saved, and the plugin reloaded.'
: `Saved. ${report.reason || 'Nothing was reloaded.'}`}
</p>
{report.rolledBack && report.reason && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
{report.reason}
</p>
)}
{report.log && (
<pre
className="sans"
style={{
background: 'var(--line-soft)',
padding: 10,
marginTop: 8,
fontSize: '0.74rem',
maxHeight: 200,
overflow: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{report.log}
</pre>
)}
{report.files.some((f) => f.rewritten) && (
<Warn>
The plugin rewrote the file as it loaded both frameworks add any settings a config is
missing and save it back, so what is on disk now is not byte-for-byte what was sent.
</Warn>
)}
</div>
)
}
export default function ModConfig() {
const [serverId, setServerId] = useState('')
const [path, setPath] = useState('')
const [tier, setTier] = useState('form')
const [edits, setEdits] = useState({})
const [raw, setRaw] = useState('')
const [reload, setReload] = useState('')
const [revealed, setRevealed] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [report, setReport] = useState(null)
const [fileNonce, setFileNonce] = useState(0)
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
// The tree is asked for per server and never cached across one: what is on a
// host's disk has no stale answer worth showing, and a plugin loaded a minute
// ago has to be able to appear.
const { data: tree, error: treeError } = useAsync(
() => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)),
[serverId],
)
const { data: file, error: fileError } = useAsync(
() => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)),
[serverId, path, fileNonce],
)
const reset = useCallback(() => {
setEdits({})
setRevealed({})
setError('')
}, [])
// A freshly opened file starts from what the host holds: the raw editor's text
// and the reload target's guess both come from the answer rather than from
// whatever the previous file left behind.
//
// **The guess is only taken when the dropdown actually offers it.** A `<select>`
// whose value matches no `<option>` displays the first one, so a guess of
// `RunicGateway` — which is deliberately not offered, because the bridge cannot
// reload itself — put "nothing — just write the file" on the screen while the
// request carried `reload: RunicGateway`, and every save of our own config was
// refused for a reason the page had just said did not apply.
useEffect(() => {
if (!file) return
setRaw(file.text)
const offered = (tree ? tree.loaded : []).some(
(p) => p.name === file.plugin && p.name !== (tree && tree.self),
)
setReload(offered ? file.plugin : '')
reset()
}, [file, tree, reset])
useEffect(() => {
setPath('')
setReport(null)
}, [serverId])
if (serverError) return <ErrorState error={serverError} />
if (!servers) return <Loading />
const rows = servers.servers || servers || []
const change = (field, value) => setEdits((current) => ({ ...current, [field.path]: { field, value } }))
const save = async () => {
setBusy(true)
setError('')
setReport(null)
try {
const body =
tier === 'form'
? {
path,
version: file.version,
...(reload ? { reload } : {}),
// A number goes up as the TEXT that was typed. `2.50` stays
// `2.50` and `1.0` stays `1.0`; turning either into a JavaScript
// number here is precisely the bug the server half exists to
// avoid, and it would be reintroduced in the browser.
edits: Object.values(edits).map(({ field, value }) =>
field.type === 'number'
? { pointer: field.pointer, raw: String(value) }
: { pointer: field.pointer, value },
),
}
: { path, version: file.version, ...(reload ? { reload } : {}), text: raw }
const answer = await api.adminConfig.save(serverId, body)
setReport(answer.report || null)
if (!answer.changed) setError('Nothing changed, so nothing was written.')
// Re-read either way: a successful reload usually rewrites the file with
// the defaults it was missing, and a rollback means what is on disk is no
// longer what is on the screen.
setFileNonce((n) => n + 1)
} catch (err) {
setError(err.message || 'That save did not work.')
} finally {
setBusy(false)
}
}
const pending = Object.keys(edits).length
return (
<div style={{ maxWidth: 980 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
These are the configuration files on the game host itself, read live through the bridge. A
save backs the file up, writes it, reloads the plugin you name, and <strong>puts the old
file back automatically</strong> if the plugin does not come back. The games data
directory kit cooldowns, zone definitions, the permission store is not settings and is
never listed here.
</p>
<Card title="Server" subtitle={`${rows.length} configured`}>
<select className="input" value={serverId} onChange={(event) => setServerId(event.target.value)}>
<option value="">Choose a server</option>
{rows.map((row) => (
<option key={row.id} value={row.id}>
{row.name || row.id}
</option>
))}
</select>
{tree && tree.root && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
{tree.root}
{tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''}
</p>
)}
</Card>
{serverId && treeError && <ErrorState error={treeError} />}
{serverId && !treeError && !tree && <Loading />}
{tree && (
<Card title="Files" subtitle="grouped by the plugin each one probably belongs to">
{tree.plugins.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
This server reports no configuration files.
</p>
)}
{tree.plugins.map((group) => (
<div key={group.plugin} style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<strong className="sans" style={{ fontSize: '0.88rem', fontWeight: 500 }}>
{group.title || group.plugin}
</strong>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{group.loaded ? `loaded · ${group.version}` : 'not loaded'}
{group.isBridge ? ' · this bridge' : ''}
</span>
</div>
{group.files.map((entry) => (
<div
key={entry.path}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '4px 0 4px 12px' }}
>
<button
type="button"
className={entry.path === path ? 'btn btn-primary' : 'btn btn-ghost'}
disabled={!entry.editable}
onClick={() => {
setPath(entry.path)
setReport(null)
setTier('form')
}}
>
{entry.path}
</button>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{Math.round(entry.bytes / 102.4) / 10} KB
{entry.modified ? ` · changed ${ago(entry.modified)}` : ''}
{entry.reason ? ` · ${entry.reason}` : ''}
</span>
</div>
))}
{!group.loaded && (
<Warn>
Nothing on this server is loaded under that name, so a save here is written and
not reloaded. It applies the next time the plugin loads.
</Warn>
)}
</div>
))}
</Card>
)}
{path && fileError && <ErrorState error={fileError} />}
{path && !fileError && !file && <Loading />}
{file && (
<Card
title={file.path}
subtitle={tier === 'form' ? `${pending} unsaved` : 'raw JSON'}
actions={
<>
<button
type="button"
className={tier === 'form' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('form')}
>
Settings
</button>
<button
type="button"
className={tier === 'raw' ? 'btn btn-primary' : 'btn btn-ghost'}
onClick={() => setTier('raw')}
>
Raw JSON
</button>
</>
}
>
{file.parseError && (
<Warn tone="#e05a5a">
This file is not valid JSON on the server ({file.parseError}), so there is nothing to
draw a form from. Raw JSON is the tier that can fix it.
</Warn>
)}
{file.isBridge && (
<Warn>
This is the bridges own configuration. Its address, port and server id are read-only
here changing any of them from the website would cut the link carrying the change,
or strand every row this site holds for this server. They are editable on the host
itself. This plugin also cannot be reloaded from here.
</Warn>
)}
{tier === 'form' && file.fields && (
<div style={{ marginTop: 6 }}>
{file.fields
.filter((field) => field.path !== '')
.map((field) => (
<Field
key={field.path}
field={field}
value={
edits[field.path]
? edits[field.path].value
: field.type === 'number'
? field.raw
: field.value
}
onChange={change}
revealed={Boolean(revealed[field.path])}
onReveal={(p) => setRevealed((current) => ({ ...current, [p]: !current[p] }))}
/>
))}
</div>
)}
{tier === 'raw' && (
<textarea
className="input"
spellCheck={false}
value={raw}
onChange={(event) => setRaw(event.target.value)}
style={{ width: '100%', minHeight: 360, fontFamily: 'monospace', fontSize: '0.8rem' }}
/>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 12,
borderTop: '1px solid var(--line-soft)',
paddingTop: 12,
}}
>
<label className="sans dim" style={{ fontSize: '0.78rem' }}>
Reload
</label>
{/* A guess, and it says so. The folder a config sits in is convention
rather than contract, so reloading it silently is how the wrong
plugin gets reloaded, reports success, and the edited one never
re-reads anything. */}
<select className="input" value={reload} onChange={(event) => setReload(event.target.value)}>
<option value="">nothing just write the file</option>
{(tree ? tree.loaded : [])
.filter((p) => p.name !== tree.self)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
<span style={{ flex: 1 }} />
<button
type="button"
className="btn btn-primary"
disabled={busy || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
onClick={save}
>
{busy ? 'Saving…' : 'Save and reload'}
</button>
</div>
{/* Beside the button, not at the top of the page. A save is made at the
bottom of a long form, and a refusal rendered above the fold is a
click that visibly did nothing. */}
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.82rem', margin: '8px 0 0' }}>
{error}
</p>
)}
<Report report={report} />
</Card>
)}
{serverId && <History serverId={serverId} nonce={fileNonce} />}
</div>
)
}
/** Who changed what, including the saves that were refused or undone. */
function History({ serverId, nonce }) {
const { data } = useAsync(() => api.adminConfig.writes(serverId), [serverId, nonce])
if (!data || !data.writes || data.writes.length === 0) return null
return (
<Card title="Recent changes" subtitle="every save, including the ones that did not land">
{data.writes.map((row) => (
<div
key={row.id}
className="sans"
style={{ padding: '8px 0', borderTop: '1px solid var(--line-soft)', fontSize: '0.82rem' }}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<strong style={{ fontWeight: 500 }}>{row.path}</strong>
<span
className="sans"
style={{ fontSize: '0.74rem', color: row.outcome === 'applied' ? 'var(--ink)' : '#d08a2a' }}
>
{row.outcome}
{row.reloaded ? ' · reloaded' : ''}
</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
{ago(row.createdAt)}
{row.tier === 'raw' ? ' · raw' : ''}
</span>
</div>
{(row.changes || []).map((change, index) => (
<div key={`${row.id}-${index}`} className="dim" style={{ fontSize: '0.74rem' }}>
{change.path}
{change.from !== null && change.to !== null ? `: ${change.from}${change.to}` : ''}
</div>
))}
{row.detail && (
<div className="dim" style={{ fontSize: '0.74rem' }}>
{row.detail}
</div>
)}
</div>
))}
</Card>
)
}

View File

@@ -0,0 +1,617 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// R2's authoring surface, and this module's first admin page.
//
// **What is on it is decided by what an operator can get wrong**, rather than by
// what the tables contain. Four states are invisible from the game and from a
// list of grants, and every one of them looks exactly like success:
//
// • a grant against somebody who has linked no Steam account — authored,
// stored, pushed nowhere;
// • a permission no loaded plugin has registered — the grant lands silently
// nowhere, because `GrantUserPermission` no-ops for an unregistered name;
// • a group member who has never connected — the store has no user record to
// put in a group yet, and the membership waits for their first connection;
// • a server whose last sync failed — the site is authoritative and the game
// has not heard it.
//
// So each of those is a sentence on this page rather than a number in a report.
//
// The screen never writes to a game. Every button here writes to the site and
// the mirror's loop reconciles within seconds — except *Sync now*, which runs
// that pass immediately because an operator who has just changed something
// should not have to trust a timer to find out that a host is unreachable.
import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
const FLEET = '*'
/** Shared furniture. The kit is nine exports and none of them is a table. */
function Card({ title, subtitle, children, actions }) {
return (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
<span style={{ flex: 1 }} />
{actions}
</header>
{children}
</section>
)
}
function Row({ children, muted = false }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 0',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.86rem',
color: muted ? 'var(--ink)' : 'var(--head)',
}}
>
{children}
</div>
)
}
function Warn({ children }) {
return (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '6px 0 0' }}>
{children}
</p>
)
}
function Scope({ value }) {
return (
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{value === FLEET ? 'every server' : value}
</span>
)
}
/**
* One server's mirror state.
*
* `unresolved` and `pending` are rendered as sentences rather than counts
* because each is a different problem with a different fix, and both are
* invisible everywhere else on this page.
*/
function ServerState({ row, onSync, busy }) {
const report = row.report || {}
const unresolved = report.unresolved || []
const pending = report.pending || []
return (
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{row.serverId}
</span>
<span
className="sans"
style={{ fontSize: '0.76rem', color: row.inSync ? 'var(--ink)' : '#d08a2a' }}
>
{row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'}
</span>
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
</span>
<span style={{ flex: 1 }} />
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
{busy ? 'Syncing…' : 'Sync now'}
</button>
</div>
{row.error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.78rem', margin: '4px 0 0' }}>
{row.error}
</p>
)}
{unresolved.length > 0 && (
<Warn>
{unresolved.join(', ')} no plugin loaded on this server has registered{' '}
{unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '}
{unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when
the plugin is back.
</Warn>
)}
{pending.length > 0 && (
<Warn>
{pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a
first connection this server has never seen those players, so it has no account to put
in a group yet.
</Warn>
)}
</div>
)
}
/** A hand edit, with the two answers to it. */
function DriftRow({ row, onAdopt, onRevoke, busy }) {
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
return (
<Row>
<span style={{ minWidth: 0, flex: 1 }}>
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
<span className="dim" style={{ fontSize: '0.78rem' }}>
{row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
{row.serverId} · seen {ago(row.firstSeen)}
</span>
</span>
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
Revoke
</button>
</Row>
)
}
/**
* The memberships the game could not place yet, as `steamId:group`.
*
* Read out of each server's own report, because it is the only thing that knows:
* a member who has never connected to a server has no user record there to put
* in a group (§12.2 rule 4), and from every other angle they look like a member.
* The server strip says how many; this is what puts it next to the person.
*/
function pendingSet(servers) {
const pending = new Map()
for (const server of servers) {
for (const entry of (server.report && server.report.pending) || []) {
if (!pending.has(entry)) pending.set(entry, [])
pending.get(entry).push(server.serverId)
}
}
return pending
}
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
const [busy, setBusy] = useState(false)
const [member, setMember] = useState('')
const [permission, setPermission] = useState('')
const act = async (fn) => {
setBusy(true)
setError('')
try {
await fn()
await onChanged()
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
const save = (permissions) =>
act(() =>
api.adminPermissions.saveGroup(group.name, {
title: group.title,
rank: group.rank,
scope: group.scope,
permissions,
}),
)
return (
<Card
title={group.title || group.name}
subtitle={<>{group.name} · <Scope value={group.scope} /></>}
actions={
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
>
Delete
</button>
}
>
<div className="field-label">Permissions</div>
{group.permissions.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
This group carries nothing, so being in it does nothing.
</p>
)}
{group.permissions.map((perm) => (
<Row key={perm}>
<span style={{ flex: 1 }}>{perm}</span>
{!catalogue.some((entry) => entry.permission === perm) && (
<span className="sans" style={{ color: '#d08a2a', fontSize: '0.74rem' }}>
no server has registered this
</span>
)}
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => save(group.permissions.filter((p) => p !== perm))}
>
Remove
</button>
</Row>
))}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!permission.trim()) return
save([...group.permissions, permission.trim().toLowerCase()])
setPermission('')
}}
>
<input
list="rust-permission-names"
className="input"
placeholder="kits.vip"
value={permission}
onChange={(event) => setPermission(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Add permission
</button>
</form>
<div className="field-label" style={{ marginTop: 18 }}>
Members
</div>
{group.members.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
Nobody is in this group.
</p>
)}
{group.members.map((m) => {
const waiting = m.accounts
.map((account) => pending.get(`${account.steamId}:${group.name}`))
.filter(Boolean)
.flat()
return (
<Row key={m.userId}>
<span style={{ flex: 1 }}>
{m.username}
{m.accounts.length > 0 ? (
<span className="dim" style={{ fontSize: '0.76rem' }}>
{' '}
· {m.accounts.map((a) => a.name || a.steamId).join(', ')}
</span>
) : (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· has linked no Steam account, so this reaches nobody
</span>
)}
{waiting.length > 0 && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· waiting on their first connection to {[...new Set(waiting)].join(', ')}
</span>
)}
</span>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
>
Remove
</button>
</Row>
)
})}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!member.trim()) return
act(() => api.adminPermissions.addMember(group.name, member.trim()))
setMember('')
}}
>
<input
className="input"
placeholder="website username"
value={member}
onChange={(event) => setMember(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Add member
</button>
</form>
{servers.length > 1 && group.scope !== FLEET && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
This group exists on {group.scope} only. The other servers never receive it.
</p>
)}
</Card>
)
}
export default function Permissions() {
const [reloads, setReloads] = useState(0)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [form, setForm] = useState({ name: '', title: '', scope: FLEET })
const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET })
const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads])
const reload = useCallback(() => setReloads((n) => n + 1), [])
const act = async (fn) => {
setBusy(true)
setError('')
try {
await fn()
reload()
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
return (
<div style={{ maxWidth: 900 }}>
{/* No heading of our own: core's admin chrome already draws the route's
title above the page, and a second one is the same words twice. */}
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
This site is the author of record. Groups and grants written here are pushed into each
servers own permission store, so every plugin that checks a permission honours them and a
wipe does not lose them, because they are re-pushed when the server comes back.
</p>
{/* The option source, shared by both forms. A datalist rather than a select:
a name that no server has registered is still authorable — the plugin
may simply not be loaded right now — and the warning beside it is the
honest treatment, where a closed list would be a refusal. */}
<datalist id="rust-permission-names">
{(data.catalogue || []).map((entry) => (
<option key={entry.permission} value={entry.permission} />
))}
</datalist>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.84rem' }}>
{error}
</p>
)}
<Card
title="Servers"
subtitle={`${servers.length} configured`}
actions={
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
Sync all
</button>
}
>
{servers.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
No servers are configured yet, so nothing written here reaches a game.
</p>
)}
{servers.map((row) => (
<ServerState
key={row.serverId}
row={row}
busy={busy}
onSync={(id) => act(() => api.adminPermissions.sync(id))}
/>
))}
</Card>
{(data.drift || []).length > 0 && (
<Card
title="Changed in game"
subtitle="granted at a console, not by this site"
>
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
Nothing here is undone automatically. <strong>Adopt</strong> records it as the sites
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
the next sync.
</p>
{data.drift.map((row) => (
<DriftRow
key={row.id}
row={row}
busy={busy}
onAdopt={(d) => act(() => api.adminPermissions.adoptDrift(d.id))}
onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))}
/>
))}
</Card>
)}
<Card title="Direct grants" subtitle="one person, one permission">
{(data.grants || []).length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>
Nobody holds a permission of their own yet.
</p>
)}
{(data.grants || []).map((row) => (
<Row key={row.id}>
<span style={{ flex: 1 }}>
{row.username} · <strong style={{ fontWeight: 500 }}>{row.permission}</strong>{' '}
<Scope value={row.scope} />
{row.accounts.length === 0 && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· has linked no Steam account, so this reaches nobody
</span>
)}
{/* The same warning the group's permission list carries, and it
matters more here: a grant naming a permission nothing has
registered is the failure the plugin's pre-check exists for,
and it is invisible on this row without it. */}
{!(data.catalogue || []).some((entry) => entry.permission === row.permission) && (
<span style={{ color: '#d08a2a', fontSize: '0.76rem' }}>
{' '}
· no server has registered this permission
</span>
)}
{row.source !== 'admin' && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · {row.source}</span>
)}
</span>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
>
Remove
</button>
</Row>
))}
<form
style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}
onSubmit={(event) => {
event.preventDefault()
if (!grant.username.trim() || !grant.permission.trim()) return
act(() =>
api.adminPermissions.grant({
username: grant.username.trim(),
permission: grant.permission.trim().toLowerCase(),
scope: grant.scope,
}),
)
setGrant({ username: '', permission: '', scope: FLEET })
}}
>
<input
className="input"
placeholder="website username"
value={grant.username}
onChange={(event) => setGrant({ ...grant, username: event.target.value })}
style={{ flex: '1 1 160px' }}
/>
<input
list="rust-permission-names"
className="input"
placeholder="kits.vip"
value={grant.permission}
onChange={(event) => setGrant({ ...grant, permission: event.target.value })}
style={{ flex: '1 1 160px' }}
/>
<select
className="input"
value={grant.scope}
onChange={(event) => setGrant({ ...grant, scope: event.target.value })}
>
<option value={FLEET}>every server</option>
{servers.map((row) => (
<option key={row.serverId} value={row.serverId}>
{row.serverId}
</option>
))}
</select>
<button type="submit" className="btn" disabled={busy}>
Grant
</button>
</form>
</Card>
{(data.groups || []).map((group) => (
<GroupCard
key={group.name}
group={group}
catalogue={data.catalogue || []}
servers={servers}
pending={pendingSet(servers)}
onChanged={reload}
setError={setError}
/>
))}
<Card title="New group">
<form
style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}
onSubmit={(event) => {
event.preventDefault()
if (!form.name.trim()) return
act(() =>
api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), {
title: form.title.trim() || form.name.trim(),
scope: form.scope,
permissions: [],
}),
)
setForm({ name: '', title: '', scope: FLEET })
}}
>
<input
className="input"
placeholder="vip"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
style={{ flex: '1 1 140px' }}
/>
<input
className="input"
placeholder="VIP"
value={form.title}
onChange={(event) => setForm({ ...form, title: event.target.value })}
style={{ flex: '1 1 140px' }}
/>
<select
className="input"
value={form.scope}
onChange={(event) => setForm({ ...form, scope: event.target.value })}
>
<option value={FLEET}>every server</option>
{servers.map((row) => (
<option key={row.serverId} value={row.serverId}>
{row.serverId}
</option>
))}
</select>
<button type="submit" className="btn" disabled={busy}>
Create
</button>
</form>
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
A group is created in each in-scope game as a real group, so plugins that read group
membership see it. A member who has never connected to a server joins it there on their
first connection a direct grant reaches them straight away, which is the difference
worth knowing when somebody is waiting.
</p>
</Card>
</div>
)
}

View File

@@ -0,0 +1,284 @@
// ── This module's fill for `admin.users.detail` ───────────────────────────
//
// R13's first slot, and the phase criterion as an operator meets it: the Steam
// id inside core's own user page, under core's own security panel.
//
// **The slot hands over `userId` and nothing else** — not a client. So this file
// builds its own bindings for the routes the server half registered
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
// a call belong to the same module even when the URL between them is core's.
//
// **Most users have no Rust account, so most of the time this renders nothing.**
// A panel that announced "no linked Steam accounts" on every user page in a
// community that also runs a UO shard would be noise on the overwhelming
// majority of them. Silence is the honest answer to "what does the Rust module
// know about this person" when it is nothing.
import { useCallback, useState } from 'react'
import { ago, count, duration } from '../../lib/format.js'
import { useAsync } from '../../core.js'
import api from '../../api.js'
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
/** One server's all-time totals for this player. */
function ServerRow({ server }) {
return (
<li
className="sans"
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
>
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
</span>
</li>
)
}
/** One linked Steam account: who it is, when it was linked, and the way out. */
function LinkPanel({ userId, link, onRemoved }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function unlink() {
setBusy(true)
setError('')
try {
await api.adminUserLinks.remove(userId, link.steamId)
await onRemoved()
} catch (err) {
setError(err.message || 'Could not unlink that account.')
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
{link.name || link.steamId}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{link.steamId} · linked {ago(link.linkedAt)}
{link.serverId ? ` on ${link.serverId}` : ''}
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
</div>
{/* Worth showing only when they differ: the name on the link is what
they were called when they linked, the other is what the game last
saw. A rename is the ordinary reason, and an operator reading a
support ticket wants both names. */}
{link.linkedName && link.name && link.linkedName !== link.name && (
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
Linked as {link.linkedName}.
</div>
)}
</div>
<button type="button" className="btn btn-ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</div>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
)}
{link.servers.length > 0 && (
<ul
style={{
listStyle: 'none',
margin: '12px 0 0',
padding: '12px 0 0',
borderTop: '1px solid var(--line-soft)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
{link.servers.map((server) => (
<ServerRow key={server.serverId} server={server} />
))}
</ul>
)}
</div>
)
}
/**
* Phase 7's half of the panel: what this person may do in game.
*
* It renders whenever they hold anything, INCLUDING when they have linked no
* Steam account — which is the one case worth going out of the way for. A grant
* against an unlinked person is authored, stored, pushed nowhere, and identical
* to a working one everywhere except here.
*/
function PermissionsPanel({ userId, data, onChanged }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [permission, setPermission] = useState('')
const act = async (fn) => {
setBusy(true)
setError('')
try {
await fn()
await onChanged()
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
if (!data) return null
const nothing = data.groups.length === 0 && data.grants.length === 0
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div className="field-label" style={{ marginBottom: 8 }}>
Permissions
</div>
{nothing && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
Nothing granted.
</p>
)}
{data.groups.map((group) => (
<div key={group.name} className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
<span style={{ color: 'var(--head)' }}>{group.title || group.name}</span>{' '}
<span className="dim" style={{ fontSize: '0.76rem' }}>
group · {group.scope === '*' ? 'every server' : group.scope}
{group.permissions.length ? ` · ${group.permissions.join(', ')}` : ' · carries nothing'}
</span>
</div>
))}
{data.grants.map((row) => (
<div
key={row.id}
className="sans"
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem', padding: '4px 0' }}
>
<span style={{ flex: 1, color: 'var(--head)' }}>
{row.permission}{' '}
<span className="dim" style={{ fontSize: '0.76rem' }}>
{row.scope === '*' ? 'every server' : row.scope}
{row.source !== 'admin' ? ` · ${row.source}` : ''}
</span>
</span>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminUserPermissions.revoke(userId, row.id))}
style={{ flex: 'none' }}
>
Remove
</button>
</div>
))}
{!nothing && data.reaches.length === 0 && (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
This account has linked no Steam id, so none of it reaches a game yet. It will apply by
itself when they link.
</p>
)}
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(event) => {
event.preventDefault()
if (!permission.trim()) return
act(() =>
api.adminUserPermissions.grant(userId, { permission: permission.trim().toLowerCase() }),
)
setPermission('')
}}
>
<input
className="input"
placeholder="kits.vip"
value={permission}
onChange={(event) => setPermission(event.target.value)}
style={{ flex: 1 }}
/>
<button type="submit" className="btn" disabled={busy}>
Grant
</button>
</form>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>
{error}
</p>
)}
</div>
)
}
export default function UserRustSections({ userId }) {
// Core's `useAsync` has no refresh, so a counter in the deps is how this
// re-reads after its own write (the same shape the player page uses).
const [reloads, setReloads] = useState(0)
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
const { data: permissions } = useAsync(
() => api.adminUserPermissions.list(userId),
[userId, reloads],
)
const reload = useCallback(() => setReloads((n) => n + 1), [])
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
// somebody else's page: a spinner on every user page for a module most users
// have nothing to do with is worse than a section that appears when it has
// something, and a failure here must not replace core's own user detail with an
// error card.
// **Both reads decide whether this section exists**, and the second one is the
// reason. A browser walk found it: a person can hold permissions and have
// linked no Steam account — which is exactly the state an operator most needs
// to see, because it is the one that reaches nobody — and a section gated on
// links alone hides it completely.
const holdsSomething =
permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
if (!data || (data.links.length === 0 && !holdsSomething)) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Rust</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{data.links.map((link) => (
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
))}
{data.links.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
recorded in the activity log it is the way back for a player who linked the wrong
account and cannot reach it in game.
</p>
)}
{/* Inside the same section rather than beside it: "who is this in game"
and "what may they do there" are one question asked twice, and an
operator reading a support ticket has both in front of them. The note
above belongs to the links, so it sits with them rather than under
the panel it would otherwise appear to describe. */}
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
</div>
</section>
)
}

View File

@@ -0,0 +1,278 @@
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
//
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
// is online by default — the narrowest audience, staff, unless an operator
// deliberately widens it here. A count of players is public at every setting.
//
// One fleet default and an optional override per server, because a creative or
// PvE server may reasonably publish a roll call a PvP server must not — and a
// server that has not chosen follows the fleet, so narrowing the fleet narrows
// every server that never said otherwise.
//
// The page says what "who is online" covers, because it is wider than the tab
// of the same name: the killfeed, chat and joins in the feed, and the
// leaderboard's "last seen" all name a player who was on at a given moment.
//
// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
// defaults to the clan's own members and staff, and widening it widens online
// status too, because a roster row carries it — the page says so. The same card
// lists each server's clan board: a server whose clans cannot be read, one at
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
// whose clans are a separate system and never Teams (D47).
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
const INHERIT = ''
const LABEL = {
staff: 'Staff only',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const CLAN_LABEL = {
members: 'The clans members and staff',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const CLAN_DESCRIBE = {
members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
const DESCRIBE = {
staff: 'Admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
function Card({ title, subtitle, children }) {
return (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
</header>
{children}
</section>
)
}
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle} aria-label={label}>
{inherit && <option value={INHERIT}>{inherit}</option>}
{audiences.map((a) => (
<option key={a} value={a}>{LABEL[a] || a}</option>
))}
</select>
)
}
export default function Visibility() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
const [fleet, setFleet] = useState('staff')
const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
// The form starts from what the server said and is reset from it after every
// save — the answer to a PUT is the new state, so what is on screen is always
// the site's word rather than what this page sent.
const load = useCallback((state) => {
setFleet(state.presence.fleet)
setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
}, [])
useEffect(() => {
if (data) load(data)
}, [data, load])
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const audiences = data.audiences
const rows = data.presence.servers
const dirtyFleet = fleet !== data.presence.fleet
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
const dirtyClans = clanRoster !== clans.roster
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
setSaved(false)
try {
const body = {}
if (dirtyFleet) body.fleet = fleet
if (dirtyClans) body.clanRoster = clanRoster
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
load(await api.adminVisibility.save(body))
setSaved(true)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={save} style={{ maxWidth: 900 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
Nothing on this site names who is online unless you choose to show it. That covers more than
the Online tab: the joins, deaths and chat in each servers feed, and the leaderboards last
seen, all say that a named player was on at a given moment. How many players are online is
always shown.
</p>
<Card title="Who is online" subtitle="the default for every server">
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
<AudienceSelect value={fleet} onChange={setFleet} audiences={audiences} label="Fleet default" />
<span className="dim" style={{ fontSize: '0.78rem' }}>{DESCRIBE[fleet]}</span>
</div>
</Card>
<Card title="Per server" subtitle="an override, or the default above">
{rows.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
)}
{rows.map((s) => (
<div
key={s.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 0',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.86rem',
}}
>
<span style={{ minWidth: 180, color: 'var(--head)' }}>
{s.name}
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
</span>
<AudienceSelect
value={servers[s.id] ?? INHERIT}
onChange={(v) => setServers((prev) => ({ ...prev, [s.id]: v }))}
audiences={audiences}
inherit={`Default (${LABEL[fleet] || fleet})`}
label={`Who is online on ${s.name}`}
/>
<span className="dim" style={{ fontSize: '0.78rem' }}>
{servers[s.id] ? 'its own setting' : 'follows the default'}
</span>
</div>
))}
</Card>
{widened && (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem' }}>
Wider than staff: on a PvP server, knowing who is on tells a raiding party whose base is
undefended.
</p>
)}
<Card title="Clan rosters" subtitle="who is in each clan, on every server">
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
<select
value={clanRoster}
onChange={(e) => setClanRoster(e.target.value)}
style={selectStyle}
aria-label="Who may see a clan roster"
>
{clans.audiences.map((a) => (
<option key={a} value={a}>{CLAN_LABEL[a] || a}</option>
))}
</select>
<span className="dim" style={{ fontSize: '0.78rem' }}>{CLAN_DESCRIBE[clanRoster]}</span>
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
Each clans name, colour, score and member count are always public.
</p>
{clanRoster !== 'members' && (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>
A roster also shows which members are online right now, so this shows who is on to{' '}
{clanRoster === 'public' ? 'everyone' : 'every signed-in member'} as well.
</p>
)}
<ClanBoards servers={clans.servers || []} />
</Card>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy || !dirty}>
{busy ? 'Saving…' : 'Save'}
</button>
{saved && !dirty && <span className="dim" style={{ fontSize: '0.8rem' }}>Saved.</span>}
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
</div>
</form>
)
}
/**
* What each server's clan board says about itself. Only the servers with
* something to report are listed: a board that is current, complete and read
* normally is the case that needs no sentence.
*/
function ClanBoards({ servers }) {
const notes = []
for (const s of servers) {
if (s.umodClans) {
notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the games own, and only the games clans appear on this site.'])
}
if (!s.supported) {
notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
} else if (s.truncated) {
notes.push([s, 'is at the games limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
} else if (!s.fresh) {
notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
}
}
if (!notes.length) return null
return (
<ul className="sans" style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: '0.8rem' }}>
{notes.map(([s, text], i) => (
// eslint-disable-next-line react/no-array-index-key
<li key={`${s.id}-${i}`} style={{ margin: '4px 0' }}>
<strong style={{ color: 'var(--head)' }}>{s.name}</strong> {text}
</li>
))}
</ul>
)
}
const selectStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '4px 8px',
fontSize: '0.84rem',
}

View File

@@ -0,0 +1,331 @@
// ── The player's own Rust identity ────────────────────────────────────────
//
// `/player/rust` — where a signed-in player links the Steam account they play
// on. It is the one page in this module a player is asked to *do* something on,
// and the thing they are doing matters more than it looks: from phase 7 the link
// is what in-game permissions are granted against, and from phase 13 it is what
// rewards are handed to.
//
// **A player route renders no layout of its own.** Core wraps `/player/*` in its
// own portal chrome, so this page starts at a heading — unlike the public pages
// in this module, which render `PublicLayout` themselves.
//
// The three-step instruction at the top is not decoration. Nothing else on the
// site tells a player that the code comes from the game, and a code field with no
// explanation is a code field nobody can use.
import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago, shortId } from '../../lib/format.js'
import api from '../../api.js'
/** The code field, and the four answers it can produce. */
function LinkForm({ onLinked }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
async function submit(event) {
event.preventDefault()
if (!code.trim() || busy) return
setBusy(true)
setMessage('')
setError('')
try {
const result = await api.playerLinks.confirm(code.trim())
setMessage(
result.already
? 'That account was already linked to you.'
: `Linked ${result.link.name || shortId(result.link.steamId)}.`,
)
setCode('')
await onLinked()
} catch (err) {
// Every refusal the server sends is already a sentence aimed at a player —
// "run /link again", "run /unlink in game", "try again in a minute" — so
// this renders it rather than replacing it with one of its own. The three
// are not interchangeable, and a page that flattened them into "could not
// link that code" would send a player back to the server that is down.
setError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ marginTop: 18 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'block' }}>
<span className="field-label" style={{ display: 'block', marginBottom: 6 }}>Link code</span>
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="K7M2PQ"
// The plugin's alphabet has no O, 0, I or 1, so a player reading a
// code off their screen cannot produce one but they can type a
// lowercase one, and the code is matched case-insensitively at the
// other end. Upper-casing here makes what they typed look like what
// they were shown.
maxLength={12}
autoComplete="off"
spellCheck={false}
className="input"
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', width: 160 }}
/>
</label>
<button type="submit" className="btn" disabled={busy || !code.trim()}>
{busy ? 'Checking…' : 'Link account'}
</button>
</div>
{message && (
<p className="sans" style={{ color: '#7fd0a4', fontSize: '0.86rem', margin: '10px 0 0' }}>{message}</p>
)}
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.86rem', margin: '10px 0 0' }}>{error}</p>
)}
</form>
)
}
/** One linked account, and the control that releases it. */
function LinkRow({ link, onRemoved }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function remove() {
setBusy(true)
setError('')
try {
await api.playerLinks.remove(link.steamId)
await onRemoved()
} catch (err) {
setError(err.message || 'Could not unlink that account.')
setBusy(false)
}
}
return (
<li className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
{link.name || shortId(link.steamId)}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{link.steamId} · linked {ago(link.linkedAt)}
{link.serverId ? ` on ${link.serverId}` : ''}
</div>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
)}
</div>
<button type="button" className="btn btn-ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</li>
)
}
/**
* Where an entitlement has actually landed.
*
* The server resolves the scope and marks each server, so this renders an answer
* rather than working one out — `*` means nothing to a player, and a second
* implementation of the scope arithmetic on the client is a second thing to keep
* true (see `forPlayer` in the permission model).
*/
function Reach({ reach }) {
if (!reach.length) {
return (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
No servers are configured yet
</span>
)
}
return (
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: '0.76rem' }}>
{reach.map((server) => (
<span
key={server.id}
style={{
border: '1px solid var(--line, rgba(255,255,255,0.14))',
borderRadius: 999,
padding: '2px 10px',
color: server.live ? 'var(--head)' : undefined,
opacity: server.live ? 1 : 0.65,
}}
>
{/* The word, not only the dot. A filled circle beside a hollow one is
the whole difference between "you have this in game" and "you do
not yet", which is more than a shape should have to carry — and a
reader who cannot tell the two apart gets no answer at all. */}
{server.live ? '● ' : '○ '}
{server.name} · {server.live ? 'has it' : 'waiting'}
</span>
))}
</div>
)
}
/** One group or one direct grant, drawn the same way because they read the same. */
function HeldRow({ title, subtitle, permissions, reach }) {
return (
<li className="panel" style={{ padding: '14px 16px' }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{title}</div>
{subtitle && (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{subtitle}</div>
)}
{permissions && permissions.length > 0 && (
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
{permissions.join(' · ')}
</div>
)}
<div style={{ marginTop: 10 }}>
<Reach reach={reach} />
</div>
</li>
)
}
/**
* What the site has given this player in game.
*
* Its own read, not part of the links read: an entitlement exists whether or not
* a Steam account is linked, and a player who has just been given something and
* has not linked yet is exactly the person who needs to see both halves at once.
*/
function Held({ accounts }) {
const { data, loading, error } = useAsync(() => api.playerPermissions.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const groups = data.groups || []
const grants = data.grants || []
if (!groups.length && !grants.length) {
return (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0, maxWidth: '60ch' }}>
Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on
the servers they cover.
</p>
)
}
const waiting = [...groups, ...grants].some((entry) => entry.reach.some((server) => !server.live))
return (
<>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{groups.map((group) => (
<HeldRow
key={`group:${group.name}`}
title={group.title}
subtitle={`Rank · joined ${ago(group.since)}`}
permissions={group.permissions}
reach={group.reach}
/>
))}
{grants.map((grant) => (
<HeldRow
key={`grant:${grant.permission}:${grant.scope}`}
title={grant.permission}
subtitle={grant.note || `Granted ${ago(grant.since)}`}
reach={grant.reach}
/>
))}
</ul>
{accounts === 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
None of this reaches the game yet link a Steam account above and the site pushes it
across on its next sync.
</p>
)}
{accounts > 0 && waiting && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 12, maxWidth: '60ch' }}>
A server marked <em>waiting</em> has not confirmed it yet. One that is offline catches up
when it comes back.
</p>
)}
</>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
// seconds would be asking a question nobody is waiting on.
//
// **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and
// that is the whole of its interface — so 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 (see `hooks/usePolled.js`).
const [reloads, setReloads] = useState(0)
const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads])
const links = data ? data.links : []
const reload = useCallback(() => setReloads((n) => n + 1), [])
return (
<div>
<div className="field-label" style={{ marginBottom: 12 }}>Steam accounts</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', maxWidth: '60ch' }}>
Linking tells this site which Steam account is yours, so your play on our servers appears
under your name here and so rewards and permissions the site hands out can reach you in
game.
</p>
<ol className="sans dim" style={{ fontSize: '0.86rem', marginTop: 14, paddingLeft: 20, maxWidth: '60ch' }}>
<li>Join any of our Rust servers and type <code>/link</code> in chat.</li>
<li>The server replies with a six-character code, only you can see it, and it lasts five minutes.</li>
<li>Type it below. It works once.</li>
</ol>
<LinkForm onLinked={reload} />
{loading && <Loading />}
{error && <ErrorState error={error} />}
{data && links.length > 0 && (
<ul style={{ listStyle: 'none', margin: '22px 0 0', padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{links.map((link) => (
<LinkRow key={link.steamId} link={link} onRemoved={reload} />
))}
</ul>
)}
{data && links.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 14, maxWidth: '60ch' }}>
A link covers every server this community runs a Steam account is one person wherever
they play, while stats are kept per server and per wipe. You can also type
{' '}<code>/unlink</code> in game to release one.
</p>
)}
{data && links.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 18 }}>
No Steam account is linked to this profile yet.
</p>
)}
{/* Phase 8. Rendered whether or not anything is linked: an entitlement is
authored against the website account, so it exists before a Steam id
does — and hiding it until one appears is the mistake the admin user
page shipped in phase 7 (PLAN.md §20.5). */}
<div className="field-label" style={{ margin: '30px 0 12px' }}>What you can do in game</div>
{data && <Held accounts={links.length} />}
</div>
)
}

View File

@@ -0,0 +1,161 @@
// ── One clan ──────────────────────────────────────────────────────────────
//
// A first-party Rust clan is a Team (R5), and this is its page. Core owns the
// Team — the reconciler, the access rules, the activity feed, the forum — but
// not the word "clan", so it publishes no Team page of its own (MODULE_API.md
// §3.7a). The page is this module's, and the three parts only core can render
// are contributed into places this page names:
//
// rust.clan.header ← core's `team.notify` (above the roster: an action ON the page)
// rust.clan.detail ← core's `team.activity` (the members-only feed, D49)
// rust.clan.forum ← core's `team.forum`
//
// One slot per PLACE, as module-uo does, so core never decides the layout of a
// page it does not own. **Every slot may be empty** — a core without Teams, a
// deployment with the forum switched off, a clan whose Team core has not created
// yet — and the page has to read correctly anyway. That is the phase criterion,
// and it is why nothing here says "see below" about something core may not put
// below.
//
// The roster comes from this module's own board, through the same function core
// asks when it projects a roster (D48), so the two cannot disagree about who may
// look. Below the audience the clan is still described — its name, score and
// count are public (D58) — and the roster says who may see it instead.
import { useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
import Empty from '../../components/Empty.jsx'
import { Swatch } from '../../components/Clans.jsx'
import { count, day } from '../../lib/format.js'
import api from '../../api.js'
const ID = 'rust'
export default function Clan() {
const { externalId } = useParams()
const { data, loading, error } = useAsync(() => api.clans.get(externalId), [externalId])
if (loading) {
return (
<PublicLayout shell="mid">
<Loading />
</PublicLayout>
)
}
// A mistyped or out-of-date address is not an outage, and must not read as
// one — the same rule the server page learned in phase 4.
if (error || !data || !data.clan) {
const missing = !error || error.status === 404
return (
<PublicLayout shell="mid">
<PageHeader
title={missing ? 'No such clan' : 'That clan could not be loaded'}
lead={
missing
? 'This address does not name a clan this site knows about.'
: 'The site could not read this clan just now. It is worth trying again.'
}
/>
{!missing && <ErrorState error={error} />}
<p className="sans" style={{ marginTop: 20 }}>
<Link to="/rust">Back to the server list</Link>
</p>
</PublicLayout>
)
}
const { clan, roster } = data
const serverLink = `/rust/servers/${encodeURIComponent(clan.serverId)}?tab=clans`
return (
<PublicLayout shell="mid">
<p className="sans" style={{ margin: '0 0 12px' }}>
<Link to={serverLink}> Clans on {clan.serverName || clan.serverId}</Link>
</p>
<PageHeader eyebrow="Rust clan" title={clan.name} lead={describe(clan)} />
{clan.gone && (
<p className="sans" style={{ color: 'var(--dim)', marginTop: 0 }}>
This clan has been disbanded, or has left its servers clan list. What is shown is the last the site heard.
</p>
)}
<Slot name="rust.clan.header" externalId={clan.externalId} moduleId={ID} />
<h2 className="sans" style={{ fontSize: '1rem', margin: '24px 0 8px' }}>Members</h2>
<Roster roster={roster} memberCount={clan.memberCount} gone={clan.gone} />
<Slot name="rust.clan.detail" externalId={clan.externalId} moduleId={ID} />
<Slot name="rust.clan.forum" externalId={clan.externalId} moduleId={ID} />
</PublicLayout>
)
}
function describe(clan) {
const parts = [
<Swatch key="c" color={clan.color} />,
` ${count(clan.memberCount)} ${clan.memberCount === 1 ? 'member' : 'members'}`,
clan.maxMembers ? ` of ${count(clan.maxMembers)}` : '',
` · ${count(clan.score)} points`,
clan.founded ? ` · founded ${day(clan.founded)}` : '',
]
return <span>{parts}</span>
}
function Roster({ roster, memberCount, gone }) {
if (!roster || !roster.visible) {
return <Empty title={`${count(memberCount)} ${memberCount === 1 ? 'member' : 'members'}`} message={withheld(roster && roster.audience)} />
}
if (roster.members.length === 0) {
return gone
? <Empty title="No roster" message="A clan that has left its servers list has no members to show." />
: <Empty title="No roster yet" message="The server has not sent this clans members yet." />
}
return (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{roster.members.map((m, i) => (
<li
// The roster carries no identifier on purpose (a Steam id and a site
// account are withheld from every public roster), so the row's place is
// its key. The list is re-rendered whole, never reordered in place.
// eslint-disable-next-line react/no-array-index-key
key={i}
style={{
display: 'flex',
alignItems: 'baseline',
gap: 12,
padding: '8px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<strong style={{ color: 'var(--ink)', flex: 1, minWidth: 0 }}>
{m.name || 'Unknown player'}
{m.leader && (
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
)}
</strong>
{m.role && !m.leader && (
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>{m.role}</span>
)}
{/* Inside the roster audience by construction (D48): a viewer who may
not see the roster sees no row to hang this on. */}
<span className="sans" style={{ color: m.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
{m.online ? 'online' : ''}
</span>
</li>
))}
</ul>
)
}
/** Why the roster was withheld, in words a visitor can act on. */
function withheld(audience) {
if (audience === 'signed_in') return 'Sign in to see who is in this clan.'
if (audience === 'public') return 'This site is not showing clan rosters right now.'
return 'Only this clans own members, with a linked Rust account, and this sites staff can see who is in it.'
}

View File

@@ -0,0 +1,189 @@
// ── One server ────────────────────────────────────────────────────────────
//
// R8's page beneath the landing page, and the phase-4 criterion lives here: it
// renders the last thing this server said while every server is off. Nothing on
// it is a live call to a game host — every panel reads this module's own tables,
// filled by the ingest cursor — so a shard that has been down for a week renders
// a week-old killfeed and a leaderboard that is still correct, rather than an
// error page.
//
// ── Everything selectable is in the URL ───────────────────────────────────
//
// Tab, feed filter, wipe and leaderboard sort all live in search parameters.
// That costs a little ceremony here and buys the thing a community site is for:
// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState`
// would make every one of those sentences unlinkable, lose the reader's place on
// a refresh, and make the browser's back button leave the page instead of
// undoing what they just clicked.
//
// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is
// the same live navigation context core's own pages use. A module with its own
// copy of react-router would get a `useParams` that returns nothing on a page
// that otherwise renders perfectly — see `core.js`'s identity check.
import { useSearchParams, useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Clans from '../../components/Clans.jsx'
import Feed from '../../components/Feed.jsx'
import Leaderboard from '../../components/Leaderboard.jsx'
import Online from '../../components/Online.jsx'
import Tabs from '../../components/Tabs.jsx'
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
import Wipes from '../../components/Wipes.jsx'
import { ago, count, day } from '../../lib/format.js'
import api from '../../api.js'
const TABS = [
{ id: 'feed', label: 'Feed' },
{ id: 'leaderboard', label: 'Leaderboard' },
{ id: 'online', label: 'Online' },
{ id: 'wipes', label: 'Wipes' },
// Phase 9. The list is public (D58); each clan's roster is on its own page.
{ id: 'clans', label: 'Clans' },
]
export default function ServerDetail() {
const { id } = useParams()
const [params, setParams] = useSearchParams()
const { data, loading, error } = useAsync(() => api.servers.get(id), [id])
const server = data ? data.server : null
const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed'
const filter = params.get('show') || 'all'
const sort = params.get('sort') || 'kills'
// `wipe` absent means all time; `wipe=current` means whatever wipe the server
// is on now, which is a moving target and therefore a word rather than an id —
// a link somebody shares stays about "now" rather than about the map that was
// current when they sent it.
const wipeParam = params.get('wipe')
const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam
const set = (key, value) => {
const next = new URLSearchParams(params)
if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key)
else next.set(key, value)
// `replace` so that flipping between tabs does not fill the reader's history
// with one entry per click — back should leave the page they arrived on.
setParams(next, { replace: true })
}
if (loading) {
return (
<PublicLayout shell="mid">
<Loading />
</PublicLayout>
)
}
// A 404 from the detail route is the one answer the other four cannot give:
// an unknown id has no events, no leaderboard and nobody online, and each of
// those empty lists is a perfectly good answer to its own question. So this is
// where "there is no such server" is said.
//
// **A mistyped address is not a fault, and must not be dressed as one.** The
// first version of this page rendered core's `ErrorState` under the heading and
// the result read "No such server / Something went wrong" — which sends a
// reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for
// the case it is for: a request that failed for a reason nobody can see.
if (error || !server) {
const missing = !error || error.status === 404
return (
<PublicLayout shell="mid">
<PageHeader
title={missing ? 'No such server' : 'That server could not be loaded'}
lead={
missing
? 'This address does not name a server this site follows.'
: 'The site could not read this server just now. It is worth trying again.'
}
/>
{!missing && <ErrorState error={error} />}
<p className="sans" style={{ marginTop: 20 }}>
<Link to="/rust">Back to the server list</Link>
</p>
</PublicLayout>
)
}
return (
<PublicLayout shell="mid">
<PageHeader
eyebrow="Rust"
title={server.name}
lead={describeWorld(server)}
/>
<div
className="sans"
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
>
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
{server.online
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
: 'Offline'}
</span>
{/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site
last wrote the row, which a FAILED poll does too. Reading the second
as the first is what made an offline server claim it had reported just
now, every thirty seconds, for as long as it stayed down. */}
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
</span>
<span style={{ marginLeft: 'auto' }}>
<WipeSelect
serverId={server.id}
value={wipeParam}
currentWipeId={server.wipeId}
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
/>
</span>
</div>
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
{tab === 'feed' && (
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
)}
{tab === 'leaderboard' && (
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
)}
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
{tab === 'clans' && <Clans serverId={server.id} />}
{tab === 'wipes' && (
<Wipes
serverId={server.id}
currentWipeId={server.wipeId}
selected={wipeId}
// Picking a wipe here is a navigation as much as a filter: it is the
// question "what happened during that map", and the answer is the feed.
onSelect={(value) => {
const next = new URLSearchParams(params)
next.set('wipe', value)
next.delete('tab')
setParams(next, { replace: true })
}}
/>
)}
</PublicLayout>
)
}
/** The world line under the heading — the things a Rust player asks first. */
function describeWorld(server) {
const parts = [
server.level || null,
server.worldSize ? `size ${count(server.worldSize)}` : null,
server.seed ? `seed ${server.seed}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'
}

View File

@@ -0,0 +1,125 @@
// ── The server list, and the module's landing page ────────────────────────
//
// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath
// it. The route is registered with an empty path in `entry.jsx` — core turns
// that into the module's own namespace root — so this page's address is the one
// an operator links to when they mean "our Rust servers".
//
// An ordinary React component. Nothing about being inside a module changes how
// you write one; the only differences are where React comes from (core, via the
// aliases in `vite.config.js`, so the import below looks completely normal and is
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
//
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
// routes in its maintenance gate and nothing else, so a page that omits the
// layout renders bare; without a `shell` it renders full-bleed with the footer
// riding up underneath it. Name a width, never a class — the classes are core's
// (MODULE_API.md §3.3).
//
// **This page never calls a game server.** Every field it renders comes from
// this module's own tables, written by the ingest cursor, which is what lets it
// render "offline, last seen an hour ago" instead of an error page when a shard
// is down. The site's availability does not depend on the game's.
import { Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Empty from '../../components/Empty.jsx'
import { ago, count, day } from '../../lib/format.js'
import api from '../../api.js'
/** The "last reported" line, which has three cases and not one. */
function reported(server) {
if (!server.lastSeenAt) return 'This server has never reported.'
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
return `Last reported ${ago(server.lastSeenAt)}.`
}
export default function Servers() {
// `useAsync` is core's fetch/loading/error hook, and the components below are
// its states. Using them rather than rolling your own is what makes a module
// page indistinguishable from a core one while it loads and while it fails.
//
// It loads once, deliberately. The DETAIL page polls, because that is where
// somebody watching a server sits; a list is a place people pass through.
const { data, loading, error } = useAsync(() => api.servers.list(), [])
const servers = data ? data.servers : []
return (
<PublicLayout shell="mid">
<PageHeader
// `lead`, not `subtitle`. PageHeader takes `eyebrow`, `title`, `lead` and
// `center`, and an unknown prop on a React component is silently dropped
// so a page written with `subtitle` renders its title and nothing else,
// on a site where every core page has a line under its heading.
title="Servers"
lead="Every Rust server this community runs, as each one last reported itself"
/>
{loading && <Loading />}
{error && <ErrorState error={error} />}
{/* An operator who has configured no servers is not an error and not an
empty game — it is an install that is not finished. Saying so beats a
blank page that looks like a failure. */}
{data && servers.length === 0 && (
<Empty
title="No servers yet"
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
/>
)}
{servers.length > 0 && (
<div style={{ display: 'grid', gap: 12 }}>
{servers.map((server) => (
// The whole row is the link. A server's name being the only clickable
// part is the thing people miss on a list of cards, and `a.card`
// already carries core's own hover treatment.
<Link
key={server.id}
to={`/rust/servers/${encodeURIComponent(server.id)}`}
className="card"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '1rem',
padding: '16px 20px',
}}
>
<span>
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
{[
server.level || null,
server.worldSize ? `size ${count(server.worldSize)}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
]
.filter(Boolean)
.join(' · ')}
</span>
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
{/* `lastSeenAt`, never `updatedAt`. The second is when THIS
site last wrote the row — which a failed poll does too — so
a page reading it told a reader that a server down for three
days had reported just now. And `stale` is a first-class
part of the answer rather than something inferred from a
timestamp: the server decides what counts as stale, because
the server knows how often a sidecar is supposed to check in. */}
{reported(server)}
</span>
</span>
<span
className="sans"
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
>
{server.online
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
: 'Offline'}
</span>
</Link>
))}
</div>
)}
</PublicLayout>
)
}

View File

@@ -0,0 +1,16 @@
// `react/jsx-runtime`, from core.
//
// Every .jsx file this module compiles becomes imports from `react/jsx-runtime`
// under the automatic runtime, which is the default the tooling assumes. Those
// have to resolve to CORE's React like every other import — a second jsx runtime
// bound to a second React is the same one-React violation as bundling `react`
// itself, only harder to see, because it shows up as a hook dispatcher error in
// a component that looks fine.
import { rg } from './rg.js'
const jsxRuntime = rg().jsxRuntime
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
export default jsxRuntime.default ?? jsxRuntime

14
client/src/shim/react-dom.js vendored Normal file
View File

@@ -0,0 +1,14 @@
// `react-dom/client`, from core.
//
// A module never calls `createRoot` — core owns the root and the module renders
// inside it. This exists because a transitive import can still reach for
// react-dom, and one that resolved to a bundled copy would put a second
// renderer in the page.
import { rg } from './rg.js'
const reactDom = rg().reactDom
export default reactDom.default ?? reactDom
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom

32
client/src/shim/react-router-dom.js vendored Normal file
View File

@@ -0,0 +1,32 @@
// `react-router-dom`, from core.
//
// The sharpest of the four, because router state is not just a library — it is
// one live navigation context. A module with its own copy would get a router
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
// instead of the SPA, on a page that otherwise renders perfectly.
import { rg } from './rg.js'
const router = rg().router
export default router.default ?? router
export const {
BrowserRouter,
Link,
NavLink,
Navigate,
Outlet,
Route,
Routes,
createSearchParams,
generatePath,
matchPath,
useLocation,
useMatch,
useNavigate,
useOutletContext,
useParams,
useResolvedPath,
useSearchParams,
} = router

50
client/src/shim/react.js vendored Normal file
View File

@@ -0,0 +1,50 @@
// The shared React, taken from core rather than bundled.
//
// Why a shim file exists at all (MODULE_API.md §3.6, and the spike proved it the
// hard way): Rollup's `external` alone emits a bare `import 'react'` into the
// chunk, which the browser cannot resolve without an import map — and an import
// map has to be an inline `<script type="importmap">`, which core's
// `script-src 'self'` forbids. `output.globals` does not help either; it is
// iife/umd only, and this is an ES module. So each shared dependency is aliased
// to a two-line module that re-exports from the global core published before any
// module chunk evaluated.
//
// The named re-exports are not decoration: `import { useState } from 'react'`
// compiles to a named import, and a module with only a default export would fail
// at link time in the browser with a message about the binding, not about this.
import { rg } from './rg.js'
const react = rg().react
export default react.default ?? react
export const {
Children,
Component,
Fragment,
StrictMode,
Suspense,
cloneElement,
createContext,
createElement,
forwardRef,
isValidElement,
lazy,
memo,
useCallback,
useContext,
useDebugValue,
useDeferredValue,
useEffect,
useId,
useImperativeHandle,
useInsertionEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
useSyncExternalStore,
useTransition,
} = react

29
client/src/shim/rg.js Normal file
View File

@@ -0,0 +1,29 @@
// The one place this module reads `window.__rg`, and the one place that says
// something useful when it is not there.
//
// Every shim beside this file, and `src/core.js`, go through here. That is not
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
// modules evaluate dependencies in the source order of their import statements,
// so "put the friendly check in the file that is imported first" is a guarantee
// that survives exactly until someone sorts the imports. Whichever module the
// bundler happens to reach first, it reaches `window.__rg` through this.
//
// A missing global means core did not publish its shared dependencies before
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
// §3.1), not a fault in this module. Without this, the first symptom is
// "Cannot read properties of undefined (reading 'react')" thrown from a file
// called react.js, which reads like the module bundled React wrong — the
// opposite of what happened.
export function rg() {
const shared = window.__rg
if (!shared) {
throw new Error(
'[rust] window.__rg is missing — core did not publish its shared dependencies before this ' +
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
'fault in this module.',
)
}
return shared
}
export default rg

154
client/test/build.test.js Normal file
View File

@@ -0,0 +1,154 @@
// What can be checked about the client half without a browser.
//
// Not much, and being honest about that is the point: the client half's real
// failures are timing and resolution, and neither has a shape a DOM-less test
// runner can see. MODULE_API.md §7.7's four-step browser smoke is what actually
// proves this half works, and it is re-run whenever this seam changes.
//
// What IS testable here is the configuration that decides resolution — and one
// of these tests exists because the trap it guards cost this project real time: Vite's object-form `resolve.alias` does PREFIX matching, so a `react`
// key silently also rewrites `react/jsx-runtime`. An anchored regexp in the
// array form cannot. That is a property of the config, and a test can hold it.
import test from 'node:test'
import assert from 'node:assert'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const CLIENT = path.resolve(HERE, '..')
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
const configModule = await import('../vite.config.js')
const config = configModule.default
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
test('every alias is an anchored regexp, never a bare prefix string', () => {
const aliases = config.resolve.alias
assert.ok(Array.isArray(aliases), 'alias must use the ARRAY form — the object form prefix-matches')
for (const { find } of aliases) {
assert.ok(find instanceof RegExp, `alias "${find}" is a string; a string prefix-matches`)
assert.ok(find.source.startsWith('^') && find.source.endsWith('$'), `alias ${find} is not anchored`)
}
})
test('react and react/jsx-runtime resolve to different shims', () => {
// The exact collision the object form causes. Asserted on the outcome rather
// than on the config's shape, so it keeps holding however the config is
// rewritten.
const resolve = (specifier) =>
config.resolve.alias.find(({ find }) => find.test(specifier))?.replacement
assert.ok(resolve('react'))
assert.ok(resolve('react/jsx-runtime'))
assert.notStrictEqual(resolve('react'), resolve('react/jsx-runtime'))
})
test('every shared dependency is aliased', () => {
for (const specifier of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'react-router-dom']) {
assert.ok(
config.resolve.alias.some(({ find }) => find.test(specifier)),
`${specifier} is not aliased — it would be bundled, giving the page a second copy`,
)
}
})
test('rollup external stays empty — it preempts the aliases rather than backing them up', () => {
// Rollup asks `external` BEFORE Vite's alias resolver runs, so a specifier
// listed in both is marked external and never aliased. The chunk then ships
// bare `import 'react'`, which no browser can resolve without an import map
// and CSP forbids one. §3.6 shows both; they do not compose.
assert.deepStrictEqual(config.build.rollupOptions.external, [])
})
test('the not-bundled guard covers every shared specifier and is not derived from them', () => {
// The direction of this dependency is the finding. Deriving the forbidden
// package list FROM the alias list means deleting an alias also deletes the
// guard against what that alias prevented — which is precisely when the guard
// is needed. So the guard states the contract, and this asserts the aliases
// stay inside it.
const packages = new Set(guardedPackages)
for (const { specifier } of SHARED) {
const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]
assert.ok(packages.has(pkg), `${pkg} is aliased but not guarded against being bundled`)
}
})
test('every alias points at a shim file that exists', () => {
for (const { find, replacement } of config.resolve.alias) {
assert.ok(fs.existsSync(replacement), `alias ${find} points at a missing file: ${replacement}`)
}
})
test('the build emits one unhashed entry.js, which is what module.json names', () => {
assert.deepStrictEqual(config.build.lib.formats, ['es'])
assert.strictEqual(config.build.lib.fileName(), 'entry.js')
const manifest = JSON.parse(fs.readFileSync(path.resolve(CLIENT, '..', 'module.json'), 'utf8'))
assert.strictEqual(manifest.client.entry, 'client/dist/entry.js')
assert.strictEqual(config.build.outDir, 'dist')
})
test('modulePreload polyfilling stays off — an inline bootstrap is refused under CSP', () => {
assert.strictEqual(config.build.modulePreload.polyfill, false)
})
test('exactly one file reads window.__rg, and every shim goes through it', () => {
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
// makes the "core did not publish its dependencies" message reachable. The
// shims touch the global before anything else in the chunk does, so a check
// placed in the first-imported file is a guarantee that lasts until someone
// sorts the imports.
const dir = path.join(CLIENT, 'src', 'shim')
const shims = fs.readdirSync(dir)
assert.ok(shims.length >= 5)
for (const file of shims) {
const source = fs.readFileSync(path.join(dir, file), 'utf8')
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
if (file === 'rg.js') {
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
continue
}
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
// A shim may import its sibling helper and nothing else — anything further
// would be a shim with a dependency to resolve, the problem it exists to remove.
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
}
}
})
test('the built chunk has no bare imports and bundles no shared dependency', () => {
// The artifact check itself, over the artifact that ships. Skipped rather than
// failed when there is no build: `npm test` must be runnable before `npm run
// build`, and CI runs them in order.
const chunk = path.join(CLIENT, 'dist', 'entry.js')
if (!fs.existsSync(chunk)) return
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
})
test('an import inside a string is not an import — the check reads code, not text', () => {
// The regression that made this necessary: the first chunk with real content
// in it had a button labelled "Approve and import" put the token
// immediately before a quote. The check rejected the whole build, naming a
// fragment of minified JSX as the offending specifier.
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
assert.deepStrictEqual(bareImports(uiCopy), [])
// Neither is one in a comment, or in a template literal.
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
// And a real one still is, in each form the build could emit.
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
// A relative specifier is a split chunk, not a shared dependency: not our concern.
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
// string must not end it early and leave the tail looking like code.
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
})

141
client/test/feed.test.js Normal file
View File

@@ -0,0 +1,141 @@
// ── The feed's sentences ──────────────────────────────────────────────────
//
// `lib/feed.js` is the one part of the client half with real branching in it, and
// it is pure on purpose so that a DOM-less runner can ask all of it. Everything
// here is a claim about what a reader sees for a given frame — which is exactly
// the kind of thing that rots silently, because a wrong killfeed line is still a
// killfeed line.
//
// The fixtures are the frames the bridge plugin actually emits (its
// `DescribeAttacker`, and PROTOCOL.md §8.4), not invented shapes.
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { describe, FEED_KINDS, FILTERS, kindsFor } from '../src/lib/feed.js'
const row = (kind, frame = {}) => ({ id: 1, kind, t: Date.now(), wipeId: 'w1', steamId: '7656', frame })
test('a player kill names the killer and the victim, in that order', () => {
const line = describe(row('player.death', {
name: 'Bob',
attackerType: 'player',
attackerName: 'Alice',
weapon: 'rifle.ak',
distance: 42.4,
grid: 'H7',
}))
assert.equal(line.tone, 'kill')
assert.equal(line.actor, 'Alice')
assert.equal(line.verb, 'killed')
assert.equal(line.subject, 'Bob')
assert.match(line.detail, /rifle ak/)
assert.match(line.detail, /42m/)
assert.match(line.detail, /H7/)
})
test('the four attacker types are four different sentences', () => {
// The plugin distinguishes them precisely so a reader does not have to guess
// from an absent field, and collapsing any two loses something: a fall reported
// as a kill by nobody is the failure this prevents.
const victim = { name: 'Bob' }
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
assert.equal(npc.actor, 'scientistnpc full any')
assert.equal(npc.subject, 'Bob')
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))
assert.equal(self.actor, 'Bob')
assert.equal(self.subject, null)
assert.match(self.verb, /own hand/)
const environment = describe(row('player.death', { ...victim, attackerType: 'environment' }))
assert.equal(environment.actor, 'Bob')
assert.equal(environment.verb, 'died')
assert.equal(environment.subject, null)
// `HitInfo` is legitimately null on the environment path, so a death frame with
// NO attacker type at all is that case — not a missing field to render around.
const bare = describe(row('player.death', victim))
assert.equal(bare.verb, 'died')
assert.equal(bare.subject, null)
})
test('a sleeping victim is said to have been sleeping', () => {
const line = describe(row('player.death', { name: 'Bob', attackerType: 'player', attackerName: 'Alice', sleeping: true }))
assert.match(line.detail, /while sleeping/)
})
test('a disconnect with no session length says nothing about one', () => {
// The plugin OMITS `sessionSec` for a player who was already on when it loaded:
// an unknown session is not a session of no length. A line reading "after 0s"
// would be a lie this module invented.
const unknown = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit' }))
assert.equal(unknown.detail, 'Quit')
const known = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit', sessionSec: 3720 }))
assert.equal(known.detail, 'Quit · after 1h 2m')
})
test('a chat line carries the message as text, never as markup', () => {
// The message is the one field on this wire whose bytes a player chooses. It
// comes back as a STRING and is rendered as a React child, which escapes it;
// this test is here so that a later "render the message with formatting" idea
// has to delete an explicit assertion rather than quietly change behaviour.
const line = describe(row('player.chat', { name: 'Bob', message: '<img src=x onerror=alert(1)>', channel: 'Global' }))
assert.equal(line.verb, '<img src=x onerror=alert(1)>')
assert.equal(typeof line.verb, 'string')
// Global is the default channel and saying so on every line is noise; Team is
// information.
assert.equal(line.detail, '')
assert.equal(describe(row('player.chat', { name: 'B', message: 'hi', channel: 'Team' })).detail, 'Team')
// A chat row is the one line where the actor is a speaker rather than a
// subject, and "Brannock see you in september" is not a sentence anybody
// writes. The colon is presentation, so it lives here and not inside the text
// the player typed.
assert.equal(line.join, ': ')
assert.equal(describe(row('player.connected', { name: 'B' })).join, undefined)
})
test('an unknown kind renders as itself rather than vanishing', () => {
// A later protocol adds kinds, and a module may be older than the game host it
// is reading. The server's allowlist has already decided the row may be seen;
// dropping it here would make the page quietly say less than the truth.
const line = describe(row('player.teleported', { name: 'Bob' }))
assert.equal(line.verb, 'player.teleported')
assert.equal(line.tone, 'other')
})
test('the feed never asks for the aggregate kind', () => {
// `player.tally` is public and is flushed once a minute per active player
// (§8.6). A feed that included it would be mostly wood counts; it is the
// leaderboard's input, and that is where it shows up.
assert.ok(!FEED_KINDS.includes('player.tally'))
for (const filter of FILTERS) {
for (const kind of filter.kinds) {
assert.ok(FEED_KINDS.includes(kind), `filter "${filter.id}" asks for ${kind}, which the feed does not carry`)
}
}
})
test('every kind the feed asks for is one the public route will serve', () => {
// Held against the module's own allowlist rather than against a copy of it: a
// kind this file asked for and `server/catalogue.js` refuses is a filter that
// silently returns nothing, which reads as a quiet server.
//
// A CommonJS file from the server half, read by an ESM test through
// `createRequire`. Crossing the two halves is fine HERE and nowhere else:
// `test/` is not shipped, and `scripts/checkImports.js` governs what is.
const catalogue = createRequire(import.meta.url)('../../server/catalogue.js')
for (const kind of FEED_KINDS) {
assert.ok(catalogue.PUBLIC_KINDS.includes(kind), `the feed asks for ${kind}, which is not public`)
}
})
test('an unknown filter falls back to everything rather than to nothing', () => {
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
})

View File

@@ -0,0 +1,96 @@
// ── Formatting ────────────────────────────────────────────────────────────
//
// Small functions, and the tests are small too — but three of them guard claims
// that would otherwise be made by a page that looks fine: an unknown duration
// rendered as zero, a timestamp in the wrong unit, and "in 0 seconds".
//
// Locale-dependent output is asserted loosely on purpose. `Intl` formats to the
// RUNNER's locale, and a test pinned to "3 minutes ago" would be a test that
// fails on a machine set to French while the page it describes is correct.
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
test('a relative time picks the unit that fits', () => {
assert.match(ago(NOW - 3 * 60_000, NOW), /3/)
assert.match(ago(NOW - 5 * 3600_000, NOW), /5/)
assert.match(ago(NOW - 3 * 86400_000, NOW), /3/)
})
test('"just now" rather than "in 0 seconds"', () => {
// What `numeric: 'auto'` produces under a minute is not what anybody means,
// and a feed row a few seconds old is the commonest row on the page.
assert.equal(ago(NOW, NOW), 'just now')
assert.equal(ago(NOW - 10_000, NOW), 'just now')
})
test('both time shapes this module serves are accepted', () => {
// `updatedAt` is an ISO string the model produced; an event's `t` is the
// millisecond stamp the plugin put on the frame. A helper that took only one
// would be a helper every caller has to remember the type for.
assert.equal(ago('2026-09-16T11:57:00.000Z', NOW), ago(NOW - 3 * 60_000, NOW))
})
test('a missing time is "never", not the epoch', () => {
assert.equal(ago(null), 'never')
assert.equal(ago(undefined), 'never')
assert.equal(ago(''), 'never')
assert.equal(day(null), 'unknown')
})
test('an unknown duration is a dash, and a short one keeps its seconds', () => {
// The distinction the plugin makes and this must not lose: `sessionSec` is
// ABSENT for a player who was already on when it loaded, so zero and unknown
// arrive at the same function and must not render the same way.
assert.equal(duration(null), '—')
assert.equal(duration(0), '—')
assert.equal(duration(40), '40s')
assert.equal(duration(90), '2m')
assert.equal(duration(3720), '1h 2m')
assert.equal(duration(7200), '2h')
})
test('a prefab reads as words, without a lookup table', () => {
assert.equal(prefab('rifle.ak'), 'rifle ak')
assert.equal(prefab('scientistnpc_full_any'), 'scientistnpc full any')
assert.equal(prefab(null), '')
})
test('a steam id is shortened without pretending to be a name', () => {
assert.equal(shortId('76561198000000001'), '…000001')
assert.equal(shortId(''), '')
})
test('a count that is not a number is zero, never NaN on the page', () => {
assert.equal(count(undefined), '0')
assert.equal(count(null), '0')
})
test("a feed row from another day carries its date, not just a time", () => {
// Found by the page walk: with the feed filtered to the previous wipe, three
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.
// Today's rows stay bare, because a killfeed of today's fights does not want
// the date on every line.
// Asserted against `Intl` rather than against a literal: a 12-hour locale puts
// letters in a bare time ("05:30 AM"), so "has letters in it" is not the test —
// "is exactly the time, and nothing else" is.
const time = (at) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const todayAt = NOW - 90 * 60_000
assert.equal(clock(todayAt, NOW), time(todayAt))
const olderAt = NOW - 46 * 86400_000
assert.ok(clock(olderAt, NOW).endsWith(time(olderAt)))
assert.ok(clock(olderAt, NOW).length > time(olderAt).length, 'an older row carries no date')
// Yesterday counts as another day even when it is only a few hours back — the
// boundary is the calendar, not a duration, because that is what a reader
// means by "what time was that".
const lateLastNight = Date.parse('2026-09-15T23:50:00')
const earlyToday = Date.parse('2026-09-16T00:20:00')
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
})

View File

@@ -0,0 +1,286 @@
// ── What the chunk registers, checked without a browser ───────────────────
//
// `build.test.js` says the honest thing about this half: its real failures are
// timing and resolution, and a DOM-less runner cannot see either. MODULE_API.md
// §7.7's browser smoke is what proves the client half works, and nothing here
// replaces it.
//
// What a test CAN do is read back what the chunk asked for. Registration is the
// one thing the chunk does at evaluation time, and it does it through an object
// core hands it — so: stand up a fake `window.__rg` with a recording registry and
// the real React behind it, import the BUILT artifact, and inspect the result. No
// DOM is needed because nothing renders; `<WorldStatus />` is `jsx(WorldStatus)`,
// an object, and the route table is full of them by design.
//
// It catches a page that silently stops being routed, a nav row whose `to` drifts
// from its route's path, and the whole registration surface disappearing because
// something threw halfway down entry.jsx.
//
// **It runs against `dist/entry.js`, so build before you test.** The skip below
// is deliberate — `npm test` has to be runnable before `npm run build` — which
// means a CI job that tests without building is a job asking nothing at all. Ours
// builds first, on purpose.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import * as react from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import * as router from 'react-router-dom'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
// Core's contribution catalogue, as of MODULE_API 1.6.0 (§3.7a). Written down
// rather than imported: this suite runs against the BUILT chunk with no core in
// the process, so it is a claim about core that has to be re-read when core's list
// changes — the same trade the rest of this fake makes.
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
// A component, as far as the registry cares. The kit's real members are core's;
// nothing renders here, so a named stub is enough to be imported and passed on.
const stub = (name) => Object.assign(() => null, { displayName: name })
function fakeRg() {
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = []
return {
version: manifest.coreApi.replace(/^\D+/, ''),
react,
jsxRuntime,
router,
// `react-dom/client` is imported for the identity check in core.js and never
// called — `createRoot` in a DOM-less process would throw. The shim reads
// this object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
registry: {
// Core's own prefixing, character for character (client/src/modules/registry.js):
// the leading separators of the module's path are stripped and so are the
// TRAILING ones, which is what lets a module register `path: ''` and own its
// namespace root — `/rust` rather than `/rust/`.
//
// This fake did the obvious `${id}/${path}` until phase 4, and the day a
// module registered an index route it produced `rust/` while a real core
// produced `rust`. The suite then failed the nav check for a link that works
// perfectly in a browser. A fake that is nearly core is worse than one that
// is obviously not: it fails on the truth.
registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
for (const r of list || []) {
const path = `${id}/${String(r.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...r, path, moduleId: id })
}
}
},
registerNav(id, { area, items }) {
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
},
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
registerExtension(id, slot, Component) {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
// The INVERTED direction (1.6.0): the module declares, core fills. Core
// enforces the namespace AND the contribution name at this call, which is why
// the fake does too — either one core would reject is a slot that renders
// nothing on a real install and everything in a suite that shrugged.
declareModuleSlot(id, name, options = {}) {
if (!name.startsWith(`${id}.`)) throw new Error(`"${name}" is not namespaced under "${id}"`)
const wants = options.core ?? null
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
throw new Error(`"${name}" asks for core contribution "${wants}", which core does not offer`)
}
declaredSlots.push({ id, name, wants })
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
// Loaded once: an ES module is evaluated a single time per process however many
// times it is imported, so every test below reads the same registration pass —
// which is also how it behaves in a browser.
let registered = null
let skip = false
if (!fs.existsSync(CHUNK)) {
skip = true
} else {
const rg = fakeRg()
globalThis.window = { __rg: rg }
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
registered = rg._read()
}
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
it('registers at least one route, namespaced under the module id', () => {
const all = Object.values(registered.routes).flat()
assert.ok(all.length > 0, 'the chunk registered no routes at all')
for (const [area, list] of Object.entries(registered.routes)) {
for (const r of list) {
// Either the namespace root itself (a module's index route, `rust`) or
// something under it (`rust/servers/:id`). `startsWith('rust/')` alone
// would reject the root — and `startsWith('rust')` alone would accept a
// hypothetical `rustling`, which is why this is spelled out.
const under = r.path === manifest.id || r.path.startsWith(`${manifest.id}/`)
assert.ok(under, `${area} route "${r.path}" is not under the namespace`)
assert.ok(r.element, `${area} route "${r.path}" has no element`)
}
}
})
it('every route path is distinct within its area', () => {
// Two routes on one path is a page that can never be reached, and React
// renders the first one without complaint.
for (const [area, list] of Object.entries(registered.routes)) {
const paths = list.map((r) => r.path)
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
}
})
it('every nav row points at a route this module actually registered', () => {
// The agreement that matters, and the one that rots quietly: a row survives a
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
// the FULL rendered path (`/rust/servers`); routes carry the namespaced
// one (`rust/servers`). Reconciling the two is the whole test.
const rendered = {
public: (p) => `/${p}`,
admin: (p) => `/admin/${p}`,
player: (p) => `/player/${p}`,
}
for (const [area, rows] of Object.entries(registered.nav)) {
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
for (const row of rows) {
assert.ok(reachable.has(row.to), `${area} nav row "${row.label}" links to ${row.to}, which no route serves`)
}
}
})
it('every admin and player nav row carries an icon', () => {
// Both of those navs draw a glyph on every core row, so a row without one reads
// as breakage rather than as a design — and core's player portal used to render
// `<n.icon />` unguarded, which blanked the entire portal with React error #130
// the first time a module registered a row without one. Core guards it now; a
// missing icon there is still a visible defect and this is the cheap place to
// catch it. The PUBLIC header is text buttons and is deliberately excluded.
for (const area of ['admin', 'player']) {
for (const row of registered.nav[area]) {
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
}
}
})
it('a nav row that gates on a feature has a provider to resolve it', () => {
// Resolution is by the REGISTERING module (§3.3), and every unknown fails OPEN.
// So a row carrying a `feature` from a module that registered no provider is a
// row that always shows — which re-advertises a surface an operator hid.
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
if (gated.length === 0) return
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
})
it('the footer slot core declares is filled, and by a component', () => {
// R13's first slot, and the half that lives in the CHUNK: `site.footer.status`
// is a CLIENT slot, so it cannot be named in `module.json`'s `extensions` —
// that array is validated against the SERVER registry and naming a client slot
// there fails the load outright. Nothing else holds this registration, and an
// extension that stopped being registered is invisible: an unfilled slot
// renders nothing, exactly as an uninstalled module does.
const footer = registered.extensions.get('site.footer.status')
assert.ok(footer, 'nothing fills site.footer.status')
assert.equal(footer.id, manifest.id)
assert.equal(typeof footer.Component, 'function')
})
it('every slot module.json declares is one the chunk fills', () => {
// `module.json` declares SERVER slots, and the loader validates those before
// the chunk is ever served. Client slots cannot be declared there — the server
// knows nothing about them — so this is the one place the two halves meet.
for (const slot of manifest.extensions || []) {
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
}
})
/** The source of every page under `src/routes`, so a slot can be looked for in all of them. */
function pageSources(dir = path.resolve(HERE, '..', 'src', 'routes'), out = []) {
if (!fs.existsSync(dir)) return out
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) pageSources(full, out)
else if (/\.jsx?$/.test(entry.name)) out.push(fs.readFileSync(full, 'utf8'))
}
return out
}
it('every declared slot is namespaced under this module and rendered by a page', () => {
// Two halves that nothing else holds together. The namespace is core's rule and
// the fake enforces it at the call; what a test has to check is the OTHER end —
// a slot declared and never rendered is a promise to core that no page keeps,
// and it fails silently, because an unrendered slot looks exactly like an
// unfilled one.
// Every page, not one named file. The kit's template reads its single slot-
// bearing page by name, which works until a module either renames that page or
// — as this one does in phase 1 — declares no slots at all: the `readFileSync`
// runs before the loop that would have been empty, and the suite dies on a
// missing file rather than passing with nothing to check.
const pages = pageSources().join('\n')
for (const { id, name } of registered.declaredSlots) {
assert.equal(id, manifest.id)
assert.ok(name.startsWith(`${manifest.id}.`), `slot "${name}" is not under the module namespace`)
assert.ok(pages.includes(`name="${name}"`), `slot "${name}" is declared and never rendered`)
}
})
it('every declared slot names a core contribution core actually offers', () => {
// The fake throws on an unknown one, exactly as core does, so this asserts the
// other half: that the slots asked for something at all. A slot with no `core`
// is legal and stays empty — which is right for a place you fill yourself and
// wrong for one you are waiting on core for, and only you know which it is.
for (const { name, wants } of registered.declaredSlots) {
assert.ok(wants, `slot "${name}" asks for no core contribution, so nothing will ever fill it`)
assert.ok(CORE_CONTRIBUTIONS.includes(wants))
}
})
it('the clan page gets all three of cores Team contributions, one per place (phase 9, D56)', () => {
// Core contributes three things to a Team page it does not own. Each has its
// own place on the clan page, so no contribution is decided by another's
// position — and a slot missing here is a clan page with no feed, no forum or
// no notification switch, with nothing logged anywhere.
const byName = Object.fromEntries(registered.declaredSlots.map((s) => [s.name, s.wants]))
assert.deepEqual(byName, {
'rust.clan.header': 'team.notify',
'rust.clan.detail': 'team.activity',
'rust.clan.forum': 'team.forum',
})
// And the page is at the address the Team provider hands core.
const paths = registered.routes.public.map((r) => r.path)
assert.ok(paths.includes('rust/clans/:externalId'), paths.join(', '))
})
it('registers under exactly one module id, matching the manifest', () => {
const owners = new Set([
...Object.values(registered.routes).flat().map((r) => r.moduleId),
...Object.values(registered.nav).flat().map((r) => r.moduleId),
...[...registered.extensions.values()].map((e) => e.id),
...[...registered.providers.values()].map((p) => p.id),
...registered.declaredSlots.map((s) => s.id),
])
assert.deepEqual([...owners], [manifest.id])
})

View File

@@ -0,0 +1,49 @@
// ── The UI kit's props, as core actually reads them ───────────────────────
//
// React drops an unknown prop without a word, so a UI-kit component called with
// the wrong one renders — just not what was written. Two of these have shipped
// from this org already: `PageHeader subtitle` (Teams phase 11, the kit's
// template) and `EmptyState title/message` (this module, phases 4 to 8 — every
// empty panel was a blank box until the presence fix's browser walk).
//
// A DOM-less runner cannot see a blank box, so this reads the source instead:
// it names the props core's components do NOT take and fails on any use of them.
// It is a claim about core that must be re-read when core's kit changes —
// written down rather than imported, because no core is in this process.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src')
/** Every .jsx/.js under src/. */
function sources(dir = SRC) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return sources(full)
return /\.(jsx?|mjs)$/.test(entry.name) ? [full] : []
})
}
// Core's `components/PageState.jsx` and `PageHeader.jsx`, read 2026-09-23 at the
// pinned core (ci/core-ref.json).
const REFUSED = {
// `EmptyState({ children })` — children only.
EmptyState: /<EmptyState\b[^>]*\b(title|message|description|text)\s*=/,
// `PageHeader({ eyebrow, title, lead, center })` — there is no `subtitle`.
PageHeader: /<PageHeader\b[^>]*\bsubtitle\s*=/,
}
test('no UI-kit component is handed a prop core does not read', () => {
const offences = []
for (const file of sources()) {
const text = fs.readFileSync(file, 'utf8')
for (const [component, pattern] of Object.entries(REFUSED)) {
if (pattern.test(text)) offences.push(`${path.relative(SRC, file)}: ${component}`)
}
}
assert.deepEqual(offences, [], 'use components/Empty.jsx for a titled empty state')
})

137
client/vite.config.js Normal file
View File

@@ -0,0 +1,137 @@
// ── The client half's library build ────────────────────────────────────────
//
// Produces `dist/entry.js`: one prebuilt ES module that core injects as a
// same-origin `<script type="module" src>` before `</body>`. The operator never
// builds anything (MODULE_SYSTEM.md §1.14), so this config is not a developer
// convenience — it is how the artifact that ships is made, and CI runs it.
//
// The normative contract is MODULE_API.md §3.6. Three mechanical details in here
// were each found the hard way and are worth reading before changing anything.
//
// **1. `resolve.alias` uses the ARRAY form with anchored regexes.** Vite's object
// form does PREFIX matching, so a `react` key also rewrites `react/jsx-runtime`
// — silently, to the wrong shim, and the chunk then fails at its first element
// with a message about `jsx` not being a function. `^react$` and
// `^react/jsx-runtime$` cannot collide.
//
// **2. The aliases replace `external`; they do not accompany it.** §3.6 shows
// both, and they do not compose: Rollup asks `external` BEFORE Vite's alias
// resolver runs, so a specifier listed there is marked external and never
// aliased. The chunk then ships bare `import 'react'` specifiers, which the
// browser cannot resolve without an import map — and core's `script-src 'self'`
// forbids the inline script an import map has to be. (`output.globals` would
// have covered iife/umd and does nothing for an ES module.) The first real module
// shipped with both, built cleanly, and emitted exactly that chunk;
// `scripts/checkExternals.js` is what caught it. So: alias only, and nothing in
// `external`.
//
// **3. What `external` was there to guard is guarded by `assertSharedNotBundled`
// below.** The risk it was covering is real — an alias that misses means a
// second React welded into the chunk, which loads fine and then throws about an
// invalid hook call somewhere unrelated. A resolution-time assertion catches
// that precisely, at build time, instead of by looking for fingerprints in
// minified output afterwards.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
const shim = (name) => fileURLToPath(new URL(`./src/shim/${name}.js`, import.meta.url))
// The shared dependencies, in one place: what a module must never bundle, and
// the shim it is aliased to instead. Adding to this list means adding to
// `window.__rg` in core, which is a MODULE_API minor bump — not a decision this
// file can make on its own.
export const SHARED = [
{ specifier: 'react', shim: 'react' },
{ specifier: 'react/jsx-runtime', shim: 'jsx-runtime' },
// A production `vite build` emits the non-dev runtime, but the plugin picks
// per mode and a `--mode development` build would reach for this one. Aliased
// rather than left to chance: the shim re-exports `jsxDEV` too.
{ specifier: 'react/jsx-dev-runtime', shim: 'jsx-runtime' },
{ specifier: 'react-dom', shim: 'react-dom' },
{ specifier: 'react-dom/client', shim: 'react-dom' },
{ specifier: 'react-router-dom', shim: 'react-router-dom' },
]
// The packages whose real source must never end up in the chunk.
//
// Stated independently of SHARED, and that is the whole point — an earlier
// version derived this from the alias list "so the two cannot disagree", which
// meant deleting an alias also deleted the guard against the thing that alias
// prevented. The guard then reported nothing on a chunk with react-router welded
// into it. What may not be bundled is a fact about core's `window.__rg`, not a
// function of what this config happens to alias; `test/build.test.js` asserts
// every SHARED specifier is covered here, which is the direction the dependency
// belongs in.
//
// `react-router` and `@remix-run/router` are react-router-dom's own internals.
// They cannot appear while the alias holds — nothing resolves through to them —
// so naming them costs nothing and closes the case where a module imports one
// directly and gets a second navigation context in a page that otherwise works.
export const SHARED_PACKAGES = ['react', 'react-dom', 'react-router-dom', 'react-router', '@remix-run/router']
/**
* Fail the build if a shared dependency's real source is about to be bundled.
*
* This is the safety net, and it is a resolution-time one on purpose. The
* alternative — grepping the built chunk for a fingerprint — has to guess at
* strings that survive minification, and guesses at that are how a check ends up
* passing on a chunk that carries a second React. Here there is nothing to
* guess: if a module id resolved into `node_modules/react`, an alias missed, and
* the alias that missed is named in the error.
*
* It hooks `transform` rather than `load`, and that is not interchangeable:
* `load` is FIRST-WINS, so an earlier plugin returning the module's contents
* means this hook is never called for it. Written against `load` this guard sat
* in the build doing nothing, and a deliberately-broken alias produced a 24 kB
* chunk with react-router welded into it and a green build — which is the exact
* failure it exists to prevent. `transform` runs for every module, every time.
*/
function assertSharedNotBundled() {
return {
name: 'rust:assert-shared-not-bundled',
enforce: 'post',
transform(code, id) {
const normalised = id.split('\\').join('/')
const hit = SHARED_PACKAGES.find((pkg) => normalised.includes(`/node_modules/${pkg}/`))
if (hit) {
this.error(
`"${hit}" resolved into node_modules (${normalised}). It must be aliased to a shim that ` +
're-exports from window.__rg — there is exactly one React in the page and core owns it ' +
'(MODULE_API.md §3.2, §3.6). Check resolve.alias in vite.config.js.',
)
}
return null
},
}
}
export default defineConfig({
plugins: [react(), assertSharedNotBundled()],
resolve: {
alias: SHARED.map(({ specifier, shim: name }) => ({
find: new RegExp(`^${specifier.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')}$`),
replacement: shim(name),
})),
},
build: {
lib: {
entry: fileURLToPath(new URL('./src/entry.jsx', import.meta.url)),
formats: ['es'],
// Unhashed, deliberately: `module.json` names this file, and a hashed name
// would have to be discovered at runtime. Core answers the cache question
// instead, serving it `no-cache` so a revalidation catches a new build
// (MODULE_API.md §3.1).
fileName: () => 'entry.js',
},
outDir: 'dist',
emptyOutDir: true,
// No inline bootstrap, for the same reason core disables it: an inline
// script is refused under `script-src 'self'`, and the failure is a chunk
// that never evaluates with a CSP report as the only clue.
modulePreload: { polyfill: false },
// `rollupOptions.external` is deliberately EMPTY — see note 2 at the top.
rollupOptions: { external: [] },
},
})

17
module.json Normal file
View File

@@ -0,0 +1,17 @@
{
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.10.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/rust"],
"admin": ["/rust"],
"player": ["/rust"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
}

200
routes.manifest.json Normal file
View File

@@ -0,0 +1,200 @@
{
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
"routes": [
{
"method": "DELETE",
"path": "/api/v1/admin/rust/permissions/grants/:id",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/rust/permissions/groups/:name",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/rust/servers/:id",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/player/rust/links/:steamId",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/config/:serverId/file",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/config/:serverId/files",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/config/:serverId/writes",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/permissions",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/permissions/catalogue",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/servers",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/visibility",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/links",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/permissions",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/links",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/permissions",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/servers",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/clans/:externalId",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/clans",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/events",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/leaderboard",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/online",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/wipes",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/config/:serverId/file",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/grants",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/groups/:name/members",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/sync",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/servers/:id/test",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/rust/permissions/grants",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/player/rust/link",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/permissions/groups/:name",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/servers/:id",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/visibility",
"tier": "public"
}
]
}

244
server/boot.js Normal file
View File

@@ -0,0 +1,244 @@
// ── The lifecycle hooks ───────────────────────────────────────────────────
//
// `register()` may not touch the database (MODULE_API.md §2.2). This file is
// where everything it could not do goes.
//
// core schema → this module's schema fragment → onBoot(ctx) → the listener binds
//
// So by the time `onBoot` runs the tables exist, core's settings are seeded, and
// nothing is serving traffic yet.
//
// **`onBoot` has no timeout.** Shutdown races the process being killed; boot does
// not. A slow `onBoot` delays the listener, which is the promise above rather
// than a problem to be timed out.
//
// **If `onBoot` throws, the module is `startup_failed` and the site still comes
// up.** Its routes stay mounted but answer 503, because a module that failed to
// warm up serving half-initialised data is worse than one that says it is down.
// There is then NO `onShutdown` — being handed a half-built world to tear down is
// worse than not closing cleanly. Which is why the poll below catches everything:
// a sidecar that is not there yet is the ordinary state of a fresh install, and
// letting that fail the boot would make installing the module before installing
// the bridge impossible.
//
// ── Three timers, and they answer three different questions ───────────────
//
// refresh (30s) what is each server, and who is on it — the BOARDS
// ingest (5s) what has happened since we last looked — the CURSOR
// prune (1h) forgetting the detail we promised not to keep for ever
//
// The boards poll and the ingest are deliberately separate rather than one loop
// reading both. They fail differently and they matter differently: a board that
// is 30 seconds stale shows a player count slightly behind, and an ingest that
// is 30 seconds behind shows a killfeed that feels broken. Splitting them lets
// the cheap one run often and the expensive one run rarely, and it means a
// sidecar that answers one and not the other degrades in exactly one place.
//
// The poll was never a placeholder for a socket: a sidecar's store-backed reads
// are what answer while a game server is off, which is most of what this module
// renders. See `ingest.js` for why the live feed is a cursor and not a
// WebSocket.
const core = require('./core')
const db = require('./model/servers/servers.db')
const eventsDb = require('./model/events/events.db')
const ingest = require('./ingest')
const permSync = require('./permSync')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
const log = core.logger('boot')
let refreshTimer = null
let ingestTimer = null
let pruneTimer = null
const REFRESH_MS = 30 * 1000
const INGEST_MS = 5 * 1000
const PRUNE_MS = 60 * 60 * 1000
/**
* How long this module keeps raw events.
*
* Longer than the sidecar's 14 days, because this is the richer store and the
* one a page reads — and because the sidecar lives on somebody's game host while
* this lives on the website's own database. What is NOT bounded by it is the
* record: `rust_player_wipe_stats` and `rust_gather_totals` are permanent, which
* is the whole of R12's "a wipe does not erase a player's history".
*/
const EVENT_RETENTION_DAYS = 30
/**
* Ask every configured sidecar how its server is doing, and store what it said.
*
* **Every server is polled independently and one failure never stops the
* others.** `Promise.allSettled`, not `Promise.all`: six servers behind one
* unreachable host would otherwise mean the whole fleet stops updating because
* one of them does, and the site would report five healthy servers offline.
*/
async function refresh() {
let rows
try {
rows = await servers.listForPolling()
} catch (err) {
log.warn('could not read the server list', { error: err.message })
return
}
await Promise.allSettled(rows.map(refreshOne))
}
async function refreshOne(server) {
try {
// One call for both boards. `/server` would answer the same question about
// the server itself, but presence would then be a second round trip to the
// same process for a fact it already had in hand.
const board = await sidecar.boards(server)
// Three outcomes, and collapsing any two of them loses something an operator
// needs:
//
// • the sidecar answered with a frame → the server has connected at least once
// • the sidecar answered 204 (`empty`) → the sidecar is up and the game never connected
// • the sidecar did not answer → the bridge is unreachable
//
// The middle case is the one that is easy to lose. It is a fresh install
// whose plugin is not loaded yet, and reporting it as unreachable sends the
// operator to look at the network instead of at the game server.
if (!board.ok) {
// `markUnreachable`, not `putState`: nothing answered, so the only new fact
// is that nothing answered. Writing the whole row from that one fact would
// blank the hostname, the map, the seed and the wipe — the last thing this
// server said, which is exactly what the pages exist to render while it is
// off.
await db.markUnreachable(server.id, false)
return
}
const boards = (board.data && board.data.boards) || {}
const frame = boards['server.hello']
if (!frame) {
// The sidecar is up and has never heard from the game. Presence is emptied
// rather than left alone: a stale list of players on a server nobody can
// reach is worse than an empty one, because it looks current.
await db.markUnreachable(server.id, true)
await ingest.applyBoards(server.id, {})
return
}
await ingest.applyBoards(server.id, boards)
await db.putState({
serverId: server.id,
reachable: true,
// A stored `server.hello` means the game connected; whether it is connected
// NOW is a different question, and `/health` is what answers it. The board
// alone cannot say, which is why `online` is not simply `true` here — it is
// decided by freshness in the model, from `updated_at`.
online: true,
players: Number(frame.players) || 0,
maxPlayers: Number(frame.maxPlayers) || 0,
hostname: frame.hostname || null,
level: frame.level || null,
seed: frame.seed === undefined ? null : Number(frame.seed),
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
bootId: frame.bootId || null,
saveCreatedAt: frame.saveCreatedAt || null,
wipeId: frame.wipeId || null,
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
raw: frame,
})
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`
// as a rejection that hides which one. Log with the id and carry on.
log.warn('could not refresh a server', { server: server.id, error: err.message })
}
}
/**
* Runs once, after the schema and before the listener binds.
*
* Receives the same frozen `ctx` `register()` was given — not a second object
* built to look like it — so a module that only needs core at boot time can skip
* `core.init` entirely and use this argument.
*/
/** Runs the cursor for every configured server, independently. */
async function ingestAll() {
let rows
try {
rows = await servers.listForPolling()
} catch (err) {
log.warn('could not read the server list', { error: err.message })
return
}
// `allSettled`, for the same reason the board poll uses it: six servers behind
// one unreachable host must not stop the other five being ingested.
await Promise.allSettled(rows.map((server) => ingest.ingestServer(server)))
}
async function prune() {
try {
const gone = await eventsDb.pruneEvents(EVENT_RETENTION_DAYS)
if (gone > 0) log.info('pruned old events', { events: gone, days: EVENT_RETENTION_DAYS })
} catch (err) {
log.warn('could not prune events', { error: err.message })
}
}
async function onBoot() {
await refresh()
// The permission mirror owns its own loop and its own cadence (see
// `permSync.js`). It is started rather than run here: a first pass would write
// to every configured game server before the website had finished booting, and
// nothing about R2 is urgent enough to delay a listener for.
permSync.start()
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
// Node keeps the process alive for a pending timer. Core's own intervals are
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
if (timer && typeof timer.unref === 'function') timer.unref()
}
log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
}
/**
* Runs on SIGINT/SIGTERM, before core closes anything of its own.
*
* The database pool, the push dispatcher and the SSE fan-out are all still open,
* because flushing through them is the only thing this hook is for. There is a
* five-second budget per module, after which the hook is abandoned — abandoned
* rather than cancelled, since nothing can stop a promise that is still running.
*/
async function onShutdown() {
permSync.stop()
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
if (timer) clearInterval(timer)
}
refreshTimer = null
ingestTimer = null
pruneTimer = null
log.info('shut down')
}
module.exports = {
onBoot,
onShutdown,
refresh,
refreshOne,
ingestAll,
prune,
REFRESH_MS,
INGEST_MS,
EVENT_RETENTION_DAYS,
}

180
server/catalogue.js Normal file
View File

@@ -0,0 +1,180 @@
// ── What the bridge can say, and who may hear it ──────────────────────────
//
// One file, because these two questions have to be answered together or the
// second one rots: which frame kinds exist, and which of them a member of the
// public may see.
//
// ── The boundary ──────────────────────────────────────────────────────────
//
// Protocol 2's catalogue includes frames carrying **IP addresses** (a login
// attempt, an approval, a ban) and **one player's complaint about another** (a
// report), and one — a destroyed structure — that names where somebody lives.
// They are stored, because an operator chasing ban evasion needs them and
// because the sidecar persists what it is told. They must never reach a public
// page.
//
// **The boundary is enforced HERE, on the side that serves, and not on the wire.**
// The plugin could have stamped a `class` on every frame and saved this file the
// trouble; it deliberately does not (PROTOCOL.md §8.5). A boundary declared by
// the sender is a boundary a compromised — or merely out-of-date — game host can
// widen. Core's own shard fan-out works the same way: a public stream with an
// allowlist of kinds, and an admin stream that adds the rest.
//
// ── Default deny, and why it is not paranoia ──────────────────────────────
//
// `isPublic` answers `false` for a kind it has never heard of. That matters
// because of the shape of the mistake it prevents: the next protocol version
// adds a kind, this module ingests it happily (`rust_events` stores what it is
// given), and a page that filtered by a DENY list would publish it the day it
// first arrived — before anybody had decided whether it should be public. With
// an allowlist the new kind is invisible until somebody adds it here, which is
// the same moment they think about it.
//
// The test holds this list against `docs/rust-link/PROTOCOL.md` §8.4's table, so
// adding a kind to the spec without classifying it fails a build rather than
// shipping an address to a public page.
/**
* Kinds a public, signed-out visitor may see.
*
* Each entry is a decision. `player.chat` is here because a shard's chat is
* public by the same logic that makes a killfeed public — it happened in front
* of everyone who was on the server — and an operator who disagrees turns the
* feature off rather than relying on this list being wrong.
*/
const PUBLIC_KINDS = Object.freeze([
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
'server.wipe',
'server.initialized',
'server.shutdown',
])
/**
* Kinds an admin may see and nobody else.
*
* Listed rather than implied by absence, so that "we know about this kind and it
* is restricted" is distinguishable from "nobody has classified this kind" — the
* second is a finding, and a bare allowlist cannot tell you which you are
* looking at.
*/
const STAFF_KINDS = Object.freeze([
'entity.destroyed',
'player.reported',
'player.banned',
'player.unbanned',
'player.login.attempt',
'player.approved',
// Protocol 3's two account frames. Neither carries a code — the code travels
// through the player, which is what makes typing it proof — but both name a
// Steam id ALONGSIDE a website account's activity, which is exactly the join a
// public page must not be able to make: "this player is that person" is a fact
// about somebody's identity, not about what happened on the server.
'account.link.requested',
'account.unlinked',
// Protocol 4. Who holds which privilege in game, and the fact that somebody
// changed it by hand — a question about a person's standing and about an
// operator's own console, neither of which is a public page's business.
'perm.drift',
// Protocol 6. Clan membership, which the org lead made members-only (D49):
// who joined which clan, and who threw whom out, is the clan's business. It
// reaches a clan's own members through core's Team feed, where core resolves
// who is a member, and it reaches the server's public feed not at all.
'clan.created',
'clan.disbanded',
'clan.member.added',
'clan.member.left',
'clan.member.kicked',
])
/**
* The public kinds that say a NAMED player was on the server at a given moment.
*
* A subset of `PUBLIC_KINDS`, not a third list: these are public-page material
* whose audience an operator chooses (`model/visibility`), where the rest of
* `PUBLIC_KINDS` is public by construction. The org lead's rule, settled
* 2026-09-22: **nothing tells who is online by default** — the narrowest
* audience (staff) unless an operator widens it, and a count is never a name.
*
* `player.death` and `player.chat` are here, and that was decided rather than
* overlooked. They are the killfeed and the chat — the content a feed exists
* for — and each one says "this person was on at 12:03" as plainly as a connect
* frame does. `player.tally` is a per-minute flush that is only ever sent for a
* player who is playing, which makes it a roll call with extra steps.
*
* What is left in the public set once these are removed is the server's own
* story — a wipe, a start, a shutdown — which names nobody.
*/
const PRESENCE_KINDS = Object.freeze([
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
])
/** Every kind the protocol defines, through protocol 6. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)
const STAFF = new Set(STAFF_KINDS)
const PRESENCE = new Set(PRESENCE_KINDS)
/**
* May a signed-out visitor see this kind?
*
* Default deny: an unknown kind is not public. Callers pass whatever arrived on
* the wire, including a kind from a newer protocol this build has never seen.
*/
function isPublic(kind) {
return PUBLIC.has(kind)
}
/** Is this a kind this build knows about at all? */
function isKnown(kind) {
return PUBLIC.has(kind) || STAFF.has(kind)
}
/** Does this kind name a player who was on the server at the time? */
function isPresence(kind) {
return PRESENCE.has(kind)
}
/**
* Narrows a list of requested kinds to the ones a viewer may have.
*
* Returning the allowlist itself when nothing was requested is what makes the
* public route safe by construction rather than by remembering to filter: there
* is no code path where "no filter" means "everything".
*
* `presence` defaults to `false` for the same reason `admin` does: a caller that
* forgets to say what the viewer may see gets the narrowest answer. The route
* resolves it from the operator's setting (`model/visibility`); nothing else
* should be passing `true`.
*/
function kindsFor({ admin = false, presence = false, requested = null } = {}) {
const permitted = admin
? ALL_KINDS
: PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k))
if (!requested || requested.length === 0) return [...permitted]
const allowed = new Set(permitted)
return requested.filter((k) => allowed.has(k))
}
module.exports = {
PUBLIC_KINDS,
STAFF_KINDS,
PRESENCE_KINDS,
ALL_KINDS,
isPublic,
isKnown,
isPresence,
kindsFor,
}

527
server/configEdit.js Normal file
View File

@@ -0,0 +1,527 @@
// ── Editing a plugin's config without rewriting the numbers ───────────────
//
// R18's base tier generates a form from a config file's VALUES — a boolean
// becomes a toggle, a number a field, a string a text box — so it works for
// whatever plugins an operator happens to have installed, including ones added
// after we shipped. This file is the half of that which cannot be done naively.
//
// ── The trap ──────────────────────────────────────────────────────────────
//
// **JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` yields
// the number `1`, and `JSON.stringify` writes it back as `1`. Both frameworks
// deserialize a config into typed C# classes, so a naive read-modify-write
// silently rewrites every whole-numbered float as an integer — **on fields
// nobody touched** — and Newtonsoft may coerce it or may throw. A throw at load
// means the plugin does not come back, and R6/R17 make four of them required.
//
// The fields at risk are exactly the ones a Rust server tunes: gather rates,
// multipliers, scales.
//
// ── So nothing here ever parses, mutates and re-serialises ────────────────
//
// `scan` is a JSON reader that records, for every value, the **span of source
// text** it came from. `applyEdits` splices new literals into those spans and
// leaves every other byte of the document exactly as it was — including the
// author's indentation, key order, and the `.0` on a float nobody edited.
//
// Two rules fall out of that and both are deliberate:
//
// 1. **A number's new value arrives as the literal text an admin typed**, never
// as a JavaScript number. `2.50` stays `2.50`; `1.0` stays `1.0`. The value
// never becomes a `Number` anywhere in this module, which is the only way to
// be sure it cannot be re-serialised into something else.
// 2. **The generated form is type-preserving.** An edit may change what a value
// IS, never what KIND of thing it is; changing a number into a string, or
// adding a key, is a structural change and belongs in the raw-JSON tier,
// where the admin is editing the document itself.
//
// Nothing in this file touches the network, a database, or core.
/** Value kinds this module names, in the language the form speaks. */
const KINDS = ['object', 'array', 'string', 'number', 'boolean', 'null']
/**
* A JSON number, by the grammar rather than by `Number()`.
*
* Used to judge a literal an admin typed. `Number('0x10')`, `Number('')` and
* `Number(' 1 ')` are all happily finite and none of the three is JSON, so the
* check has to be the grammar — which is also what keeps `1.0` and `1e3`
* acceptable, since preserving those is the entire point.
*/
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
/**
* Words that make a value a secret.
*
* Matched against the key split into WORDS, not as a substring: `Monkey` and
* `Keybind` contain "key" and neither is a credential, and a config editor that
* masked every third field would teach an operator to ignore the mask.
*/
const SECRET_WORDS = new Set([
'key',
'keys',
'apikey',
'token',
'tokens',
'secret',
'secrets',
'password',
'passwd',
'pass',
'webhook',
'webhooks',
'credential',
'credentials',
'auth',
])
class JsonScanError extends Error {}
/**
* Reads `text` into a tree of nodes that remember where they came from.
*
* Every node carries `start` and `end`, the half-open span of the value in the
* source. A caller that only wants the data can read `value`; a caller that
* wants to CHANGE the data uses the span, because the span is the only thing
* that survives a round trip unchanged.
*
* @param {string} text
* @returns {object} the root node
* @throws {JsonScanError} with a position, on anything that is not JSON
*/
function scan(text) {
const src = String(text)
let at = 0
function fail(message) {
throw new JsonScanError(`${message} at offset ${at}`)
}
function ws() {
while (at < src.length && (src[at] === ' ' || src[at] === '\t' || src[at] === '\n' || src[at] === '\r')) at += 1
}
function literal(word, value) {
if (src.startsWith(word, at)) {
const start = at
at += word.length
return { type: word === 'null' ? 'null' : 'boolean', value, start, end: at }
}
return null
}
function string() {
const start = at
at += 1 // the opening quote
let out = ''
while (at < src.length) {
const ch = src[at]
if (ch === '"') {
at += 1
return { type: 'string', value: out, start, end: at }
}
if (ch === '\\') {
const esc = src[at + 1]
at += 2
if (esc === 'u') {
const hex = src.slice(at, at + 4)
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape')
out += String.fromCharCode(parseInt(hex, 16))
at += 4
} else if (esc === 'n') out += '\n'
else if (esc === 't') out += '\t'
else if (esc === 'r') out += '\r'
else if (esc === 'b') out += '\b'
else if (esc === 'f') out += '\f'
else if (esc === '"' || esc === '\\' || esc === '/') out += esc
else fail('bad escape')
continue
}
out += ch
at += 1
}
return fail('unterminated string')
}
function number() {
const start = at
if (src[at] === '-') at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
if (src[at] === '.') {
at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
}
if (src[at] === 'e' || src[at] === 'E') {
at += 1
if (src[at] === '+' || src[at] === '-') at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
}
const raw = src.slice(start, at)
if (!JSON_NUMBER.test(raw)) fail(`'${raw}' is not a number`)
// `raw` is the fact; `value` is a convenience for rendering and comparison,
// and is never written back to the document.
return { type: 'number', value: Number(raw), raw, start, end: at }
}
function value() {
ws()
const ch = src[at]
if (ch === '{') return object()
if (ch === '[') return array()
if (ch === '"') return string()
if (ch === '-' || (ch >= '0' && ch <= '9')) return number()
const lit = literal('true', true) || literal('false', false) || literal('null', null)
if (lit) return lit
return fail('unexpected character')
}
function object() {
const start = at
at += 1 // {
const children = []
ws()
if (src[at] === '}') {
at += 1
return { type: 'object', children, start, end: at }
}
for (;;) {
ws()
if (src[at] !== '"') fail('expected a key')
const key = string()
ws()
if (src[at] !== ':') fail('expected a colon')
at += 1
const child = value()
child.key = key.value
child.keyStart = key.start
child.keyEnd = key.end
children.push(child)
ws()
if (src[at] === ',') {
at += 1
continue
}
if (src[at] === '}') {
at += 1
return { type: 'object', children, start, end: at }
}
return fail('expected a comma or a closing brace')
}
}
function array() {
const start = at
at += 1 // [
const children = []
ws()
if (src[at] === ']') {
at += 1
return { type: 'array', children, start, end: at }
}
for (;;) {
const child = value()
child.index = children.length
children.push(child)
ws()
if (src[at] === ',') {
at += 1
continue
}
if (src[at] === ']') {
at += 1
return { type: 'array', children, start, end: at }
}
return fail('expected a comma or a closing bracket')
}
}
const root = value()
ws()
if (at !== src.length) fail('trailing content')
return root
}
/** Splits a config key into words, across camelCase, snake_case, spaces and dots. */
function words(key) {
return String(key)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((w) => w.toLowerCase())
}
/**
* Whether a key names a credential. See `SECRET_WORDS`.
*
* **A field flagged here is not emptied.** D37 decided the raw tier shows real
* values — an admin can already read the file over SSH — so the API answers with
* the document as it is, the form renders a flagged field masked with a reveal
* control, and the flag's load-bearing use is the audit trail, where the values
* genuinely never appear.
*/
function isSecretKey(key) {
return words(key).some((w) => SECRET_WORDS.has(w))
}
/** A pointer as a person reads it: `Settings.Rates[0].Wood`. */
function pointerPath(pointer) {
return pointer
.map((step) => (typeof step === 'number' ? `[${step}]` : step))
.join('.')
.replace(/\.\[/g, '[')
}
/**
* Walks a scanned tree into the flat description the form is built from.
*
* **What is NOT here is as deliberate as what is.** There are no descriptions,
* no minimums, no maximums and no allowed-value sets, because a config file
* carries none: the key name is the entire label. An empty array and a `null`
* carry no type at all, so nothing can be inferred for them and they are marked
* `advanced` — the raw tier is where a value with no shape gets edited.
*
* @param {object} root from `scan`
* @param {object} [options]
* @param {number} [options.maxDepth] past this, a subtree is advanced-only
* @param {string[]} [options.locked] top-level keys that may not be edited (D38)
*/
function describe(root, { maxDepth = 6, locked = [] } = {}) {
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
const fields = []
function visit(node, pointer, depth, inheritedSecret, inheritedLock) {
const key = pointer.length ? pointer[pointer.length - 1] : ''
const secret = inheritedSecret || (typeof key === 'string' && isSecretKey(key))
const isLocked =
inheritedLock || (pointer.length === 1 && typeof key === 'string' && lockedSet.has(key.toLowerCase()))
if (node.type === 'object' || node.type === 'array') {
const tooDeep = depth >= maxDepth
fields.push({
pointer: [...pointer],
path: pointerPath(pointer),
key: typeof key === 'number' ? `[${key}]` : key,
type: node.type,
depth,
count: node.children.length,
secret,
locked: isLocked,
// An empty container has nothing to infer a member's shape from, and a
// container past the depth limit has more shape than a form should try
// to draw. Both are honest reasons to send somebody to the raw tier.
advanced: tooDeep || node.children.length === 0,
...(tooDeep ? { reason: 'deeper than the form will draw' } : {}),
...(node.children.length === 0 ? { reason: 'empty, so there is no shape to read' } : {}),
})
if (tooDeep) return
node.children.forEach((child, index) => {
visit(child, [...pointer, node.type === 'array' ? index : child.key], depth + 1, secret, isLocked)
})
return
}
fields.push({
pointer: [...pointer],
path: pointerPath(pointer),
key: typeof key === 'number' ? `[${key}]` : key,
type: node.type,
depth,
// A number is reported as its LITERAL as well as its value. The literal is
// what the form must round-trip; the value is for display and sorting.
...(node.type === 'number' ? { raw: node.raw } : {}),
value: node.value,
secret,
locked: isLocked,
// `null` has no type, so there is nothing to render but a raw editor.
advanced: node.type === 'null',
...(node.type === 'null' ? { reason: 'null carries no type to read' } : {}),
})
}
visit(root, [], 0, false, false)
return fields
}
/** Finds the node a pointer names, or null. */
function resolve(root, pointer) {
let node = root
for (const step of pointer) {
if (!node || (node.type !== 'object' && node.type !== 'array')) return null
if (node.type === 'array') {
if (typeof step !== 'number') return null
node = node.children[step]
} else {
node = node.children.find((child) => child.key === step)
}
if (!node) return null
}
return node
}
/** The exact source text a node was read from. */
function literalOf(text, node) {
return String(text).slice(node.start, node.end)
}
/**
* Turns one edit into the literal that will be spliced in, or explains why not.
*
* `raw` is used verbatim for a number — that is the whole mechanism — and is
* validated against the JSON grammar first, because verbatim and unvalidated
* would be a way to write anything at all into somebody's config file.
*/
function literalFor(node, edit) {
if (node.type === 'number') {
const raw = String(edit.raw !== undefined && edit.raw !== null ? edit.raw : edit.value).trim()
if (!JSON_NUMBER.test(raw)) return { error: `'${raw}' is not a number` }
return { literal: raw }
}
if (node.type === 'string') {
if (typeof edit.value !== 'string') return { error: 'expected text' }
return { literal: JSON.stringify(edit.value) }
}
if (node.type === 'boolean') {
if (typeof edit.value !== 'boolean') return { error: 'expected true or false' }
return { literal: edit.value ? 'true' : 'false' }
}
return { error: `a ${node.type} is edited in the raw tier` }
}
/**
* Applies a set of edits to a document and returns the new text.
*
* Spans are spliced from the **end of the document backwards**, so that an
* earlier edit never moves a later edit's offsets. Every edit is resolved and
* checked before any splice happens: a refusal leaves the caller with the
* original text rather than a partly-edited one.
*
* @param {string} text
* @param {Array<{pointer: Array<string|number>, value?: any, raw?: string}>} edits
* @returns {{ text?: string, changes?: object[], error?: string }}
*/
function applyEdits(text, edits, { locked = [] } = {}) {
let root
try {
root = scan(text)
} catch (err) {
return { error: `the file on the server is not valid JSON: ${err.message}` }
}
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
const staged = []
const seen = new Set()
for (const edit of edits || []) {
const pointer = Array.isArray(edit.pointer) ? edit.pointer : null
if (!pointer || pointer.length === 0) return { error: 'an edit must name a field' }
const path = pointerPath(pointer)
if (seen.has(path)) return { error: `'${path}' is edited twice in one save` }
seen.add(path)
if (typeof pointer[0] === 'string' && lockedSet.has(pointer[0].toLowerCase())) {
return { error: `'${path}' cannot be edited from the website` }
}
const node = resolve(root, pointer)
if (!node) return { error: `'${path}' is not in this file` }
const { literal, error } = literalFor(node, edit)
if (error) return { error: `'${path}': ${error}` }
staged.push({
path,
pointer,
start: node.start,
end: node.end,
from: literalOf(text, node),
to: literal,
secret: pointer.some((step) => typeof step === 'string' && isSecretKey(step)),
})
}
// Nothing to do is not an error, but it must not produce a write either: a
// save with no changes would spend a reload — and a reload is the one part of
// this feature that can take a plugin down.
const changed = staged.filter((s) => s.from !== s.to)
if (changed.length === 0) return { text: String(text), changes: [] }
let out = String(text)
for (const edit of [...changed].sort((a, b) => b.start - a.start)) {
out = out.slice(0, edit.start) + edit.to + out.slice(edit.end)
}
// The result must still be JSON. It always is when the pieces are — this is a
// guard against a bug in this file, not against the caller.
try {
scan(out)
} catch (err) {
return { error: `the edit produced something that is not JSON: ${err.message}` }
}
return { text: out, changes: changed.map(redactChange) }
}
/**
* What the audit trail records for one changed field.
*
* **A secret's values are never written down.** The raw tier shows real values
* to an admin who asks for them, which is a deliberate decision (D37) about a
* page somebody has to open — but an activity log is read by more people, for
* longer, and usually by somebody who was not there. Those are different
* exposures and they get different answers.
*/
function redactChange(change) {
return {
path: change.path,
from: change.secret ? '***' : change.from,
to: change.secret ? '***' : change.to,
...(change.secret ? { secret: true } : {}),
}
}
module.exports = {
KINDS,
JSON_NUMBER,
JsonScanError,
scan,
describe,
resolve,
applyEdits,
isSecretKey,
pointerPath,
words,
}

175
server/core.js Normal file
View File

@@ -0,0 +1,175 @@
// ── Everything this module reaches in core ─────────────────────────────────
//
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
// code beneath it — models, controllers, utilities — is ordinary Node that
// requires its dependencies at file scope, the way any Node file does. This file
// is what lets both of those be true at the same time.
//
// **Every export is a lazy accessor, not a stored reference, and that is the
// whole point.** A model writes
//
// const { query } = require('../../core')
//
// at require time, which is before `register()` has been called and therefore
// before any `ctx` exists. Handing out `ctx.db.query` at that moment would hand
// out `undefined`, permanently, and the failure would surface much later as a
// TypeError inside a model with no clue pointing here. So each member resolves
// `ctx` when it is CALLED. Require order stops mattering for everything except
// `core.init()` itself, which `index.js` runs first.
//
// The same rule in the other direction: **never destructure off `ctx` at init
// time.** Core is free to hand over a getter — `ctx.site.baseUrl` is one — and a
// value captured once is a value that cannot change.
//
// If `ctx` is missing every accessor throws the same message. The only ways to
// reach one before `register()` are a require cycle or a test that forgot to call
// `init`, and both want naming rather than `undefined`.
//
// ── This file is a NARROWING, on purpose ───────────────────────────────────
//
// §2.3 lists everything core hands over. What is re-exported below is only what
// this module actually uses, which is the discipline worth copying: the file is
// then an honest statement of what your module depends on, and a test double for
// it (see `test/_fakes.js`) is a complete one. Add a member here when you reach
// for it — not in advance.
let ctx = null
function need() {
if (!ctx) {
throw new Error('rust: core accessed before register() — see server/core.js')
}
return ctx
}
/** Called once, first thing in `register()`. */
function init(value) {
ctx = value
}
/** Test seam. Nothing in the module calls this; there is no de-registration. */
function _reset() {
ctx = null
}
// A logger that can be taken at require time and used after `register()`.
//
// A file writes `const log = require('../core').logger('servers')` at file scope,
// so the object returned has to exist before `ctx` does. It is a façade whose
// four methods each resolve the real logger when called. Core namespaces the
// output with your module id, so these come out as `[rust:servers]`.
function logger(namespace) {
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
}
module.exports = {
init,
_reset,
logger,
// Shared server dependencies. Core owns exactly one express, as it owns
// exactly one React on the client, and for the same reason: a second copy in
// the process is a second Router prototype and a second set of `instanceof`
// checks. A module could not resolve these for itself even if it were allowed
// to — it lives outside core's `server/` (§7.2).
get express() { return need().express },
get validator() { return need().validator },
// The database. `query(sql, params)` is what every `*.db.js` file uses; raw
// parameterised SQL, no ORM, the same as core. `pool` is there for the rare
// case that needs a connection it can hold (a streamed import, say).
query: (...args) => need().db.query(...args),
get pool() { return need().db.pool },
// Read-only access to who is asking. Minting a session is core's job; a module
// that needs an identity needs to *read* one.
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
// One user by id (MODULE_API.md §2.3, 1.1.0). Here for the presence gate
// (`model/visibility`): `getUserFromRequest` decodes a token and nothing more,
// so the role in it is the role the account had when the token was minted. A
// moderator demoted this morning would keep reading who is online until their
// token expired. Re-reading the row is what makes a demotion — or a ban — take
// effect on the next request, the same promise core's admin tier makes.
users: { getById: (...args) => need().users.getById(...args) },
// Core's middleware, taken as values rather than wrapped: express stores the
// function reference at mount time, so a wrapper is what would end up in the
// stack. Routers are built inside `register()`, so `ctx` is set by then.
get middleware() { return need().middleware },
// Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than
// exposed as `get events()`, so that `require('../core').emit` taken at file
// scope still resolves `ctx` at call time like everything else here.
//
// **It returns nothing, and in production it never throws at the caller.** The
// emit is the end of this module's involvement: core validates the payload
// against the declared contract, decides which rules match, resolves who they
// reach and sends. A module cannot address a person, choose a channel or write
// a subject line, and this seam is deliberately too narrow to try (§2.7).
//
// Outside production a bad payload throws here rather than being logged, which
// is the point: you meet the mismatch in your own tests instead of in an
// operator's log six weeks later.
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
// Secrets at rest (MODULE_API.md §2.3). Core's AES-256-GCM box, keyed by the
// deployment's `SECRET_ENC_KEY` — the same one that protects core's own OAuth
// client secrets and the uo-link token.
//
// **The sidecar token goes through this and nothing else.** It is the
// credential that reaches a game host, and it is stored encrypted and returned
// to no client ever: the admin API accepts a new value and reports only
// whether one is set. Returned as the box rather than as two wrapped functions
// so that `encrypt`/`decrypt` stay a matched pair at the call site.
secretBox: () => need().secretBox,
// The admin activity log (MODULE_API.md §2.3, 1.1.0). Every write on this
// module's admin tier goes through it, because the rows it writes are the
// credentials that reach a game host — "who changed the sidecar URL" is a
// question an operator will eventually need answered, and there is no second
// place it is recorded.
activity: { log: (...args) => need().activity.log(...args) },
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
// creature an event spawned. Only this module knows, because only this module
// watches the feed the boot id arrives on.
//
// Calling it asks core to sweep its resource ledger and put the question back
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
// forget: it returns at once and the sweep happens on core's own time.
//
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
// the answer. Named longer than the `ctx` member it wraps because this object
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
// would be worse, since a module has more than one thing it could reconcile.
reconcileEvents: () => need().events.reconcile(),
// Teams (MODULE_API.md §2.3, 1.6.0) — the push half of the provider this
// module registers (`model/clans/teamProvider.js`). Three calls, all
// fire-and-forget, and core's contract is that none of them can make this
// module's call site slow or turn a background failure into its error:
//
// publish(event) a membership or leadership change, as it happened
// reconcile({reason}) "the set may have changed, come and ask" — debounced
// pushActivity(items) the per-Team feed, idempotent on each item's dedupeKey
//
// Correctness comes from reconciliation either way; `publish` only makes a
// change visible sooner. Wrapped as calls, like `emit`, so a file that takes
// `core.teams` at require time still resolves `ctx` when it is used.
teams: {
publish: (event) => need().teams.publish(event),
reconcile: (options) => need().teams.reconcile(options),
pushActivity: (items) => need().teams.activity.push(items),
},
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
// only correct way to find a file you shipped, because the working directory is
// core's and the module's location is the loader's business.
get moduleRoot() { return need().paths.moduleRoot },
get moduleId() { return need().moduleId },
}

49
server/db/purge.sql Normal file
View File

@@ -0,0 +1,49 @@
-- ── The teardown ──────────────────────────────────────────────────────────
--
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
-- Nothing on the boot path executes this file, and uninstalling the module does
-- not either: removing an operator's data is a second decision they make on
-- purpose, offered inside the uninstall flow and confirmed separately.
--
-- It exists because `schema.sql` does. A module that can create tables and
-- cannot drop them leaves an operator with orphaned data and no supported way to
-- remove it, so core refuses to load a module that declares one without the
-- other.
--
-- **Drop in the reverse of creation order**, which this file depends on:
-- `rust_server_state` carries a foreign key into `rust_servers`, so dropping the
-- parent first fails on the constraint — and a purge that fails halfway leaves
-- exactly the orphaned data it exists to remove.
--
-- What does NOT belong here: rows written into core's tables. Core prunes what
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- Phase 7b.
DROP TABLE IF EXISTS rust_clan_boards;
DROP TABLE IF EXISTS rust_clan_members;
DROP TABLE IF EXISTS rust_clans;
DROP TABLE IF EXISTS rust_settings;
DROP TABLE IF EXISTS rust_config_writes;
-- Phase 7. Children before parents: every one of these carries a foreign key
-- into `rust_servers`, `users` or `rust_perm_groups`.
DROP TABLE IF EXISTS rust_perm_catalogue;
DROP TABLE IF EXISTS rust_perm_sync;
DROP TABLE IF EXISTS rust_perm_revocations;
DROP TABLE IF EXISTS rust_perm_drift;
DROP TABLE IF EXISTS rust_perm_pushed;
DROP TABLE IF EXISTS rust_perm_grants;
DROP TABLE IF EXISTS rust_perm_group_members;
DROP TABLE IF EXISTS rust_perm_group_permissions;
DROP TABLE IF EXISTS rust_perm_groups;
DROP TABLE IF EXISTS rust_account_links;
DROP TABLE IF EXISTS rust_ingest_cursor;
DROP TABLE IF EXISTS rust_presence;
DROP TABLE IF EXISTS rust_events;
DROP TABLE IF EXISTS rust_gather_totals;
DROP TABLE IF EXISTS rust_player_wipe_stats;
DROP TABLE IF EXISTS rust_players;
DROP TABLE IF EXISTS rust_wipes;
DROP TABLE IF EXISTS rust_server_state;
DROP TABLE IF EXISTS rust_servers;

794
server/db/schema.sql Normal file
View File

@@ -0,0 +1,794 @@
-- ── The schema fragment ───────────────────────────────────────────────────
--
-- Core replays this file on EVERY boot, statement by statement, immediately
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
--
-- There is no migration runner anywhere in this project. A module's schema is
-- not a sequence of changes to apply once — it is a statement of what the tables
-- should look like, written so that running it against a database that already
-- matches does nothing. Every CREATE carries IF NOT EXISTS; **changing a table
-- is an ALTER below the CREATE, never an edit to the CREATE**, because
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
-- there and an edited column would reach fresh installs only.
--
-- Every table here is prefixed `rust_`, which is this module's id and the only
-- prefix it may create under.
--
-- ── Four kinds of table, and the split between them is the whole design ───
--
-- CONFIGURATION `rust_servers` — rows an operator writes, from Admin → Rust.
-- OBSERVED STATE `rust_server_state`, `rust_presence` — what a sidecar last
-- reported, replaced rather than appended.
-- THE RECORD `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
-- `rust_gather_totals` — permanent, and the reason a wipe does
-- not erase a player's history.
-- THE WINDOW `rust_events` — recent detail, bounded by a sweep.
--
-- They are separate tables rather than columns on one because they have
-- different writers, different lifetimes and different audiences — and because
-- a purge of observed state while keeping the configuration is a thing an
-- operator will eventually want.
--
-- Teardown is `purge.sql`, which no boot ever runs.
-- ── The configured servers ────────────────────────────────────────────────
--
-- One row per Rust game server, and therefore one row per sidecar: the bridge is
-- one server to one sidecar, on that server's own host (R8). A community running
-- six servers has six rows here, each with its own base URL and its own token.
--
-- `id` is the operator's own slug and is what every URL under `/rust/servers/`
-- carries. It is deliberately NOT auto-increment: it appears in links people
-- share, and a row rebuilt after a mistake should be able to keep its address.
--
-- `sidecar_token_enc` holds the sidecar's shared secret **encrypted at rest**
-- through `ctx.secretBox` (MODULE_API.md §2.3), like every other secret this
-- platform stores. It is write-only in the API: the admin surface accepts a new
-- value and never returns the stored one, so a compromised admin session cannot
-- read back the credential that reaches the game host.
--
-- `protocol` records the wire version this row was configured against. It is
-- stored rather than assumed because a fleet is upgraded one host at a time, and
-- an operator needs to see WHICH server disagrees rather than that one does.
CREATE TABLE IF NOT EXISTS rust_servers (
id VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
sidecar_base_url VARCHAR(255) NOT NULL,
sidecar_token_enc TEXT NULL,
protocol INT UNSIGNED NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ── What each server last said about itself ───────────────────────────────
--
-- One row per configured server, replaced whole each time this module reads a
-- sidecar. It is the table that lets the site render while every game server is
-- off, which is the point of the sidecar holding a store at all.
--
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
-- the same numbers back — which is exactly what a quiet server looks like —
-- would leave the timestamp frozen at the first write and the row would look
-- stale while nothing was wrong. The writer sets the column explicitly instead.
--
-- `boot_id` is the game process's own identity, not the sidecar's and not the
-- plugin's. It changes when the world started over and at no other time, which
-- is what makes it the thing to watch: a reconnect of either bridge component
-- loses nothing, and a game restart loses everything an event put in the world.
--
-- `raw` keeps the whole frame. This module indexes the columns it serves and
-- stores the rest verbatim, so a protocol version that adds a field needs no
-- migration here — the same dumb-forwarder property the sidecar has, one hop
-- further along.
CREATE TABLE IF NOT EXISTS rust_server_state (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
reachable TINYINT(1) NOT NULL DEFAULT 0,
online TINYINT(1) NOT NULL DEFAULT 0,
players INT UNSIGNED NOT NULL DEFAULT 0,
max_players INT UNSIGNED NOT NULL DEFAULT 0,
hostname VARCHAR(191) NULL,
level VARCHAR(120) NULL,
seed BIGINT NULL,
world_size INT UNSIGNED NULL,
boot_id VARCHAR(64) NULL,
save_created_at VARCHAR(32) NULL,
protocol INT UNSIGNED NULL,
raw LONGTEXT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_server_state_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── The read path ─────────────────────────────────────────────────────────
--
-- Protocol 2 turned the bridge from a greeting into a catalogue, and these are
-- the tables that hold it. They divide on one line, and it is the line R12 drew:
--
-- PERMANENT `rust_wipes`, `rust_players`, `rust_player_wipe_stats`,
-- `rust_gather_totals` — a player's record, kept for ever. All-time
-- is a SUM across wipes rather than a second set of counters, so
-- there is no second number that can disagree with the first.
--
-- BOUNDED `rust_events` — the recent raw window the killfeed reads, pruned
-- on a sweep. It is detail, not record: losing last month's
-- individual deaths costs a scroll-back, losing last month's
-- totals costs a player their history.
--
-- DERIVED `rust_presence` — who is on right now, replaced wholesale from
-- the `players.online` board. Never a history, never appended.
--
-- The sidecar keeps its own bounded copy of the same events (default 14 days),
-- so shortening either window loses recent detail and neither loses a total.
-- ── Wipes ─────────────────────────────────────────────────────────────────
--
-- One row per (server, wipe). The id is the plugin's, derived from the save's
-- creation time and stamped on every frame (PROTOCOL.md §8.2) — this module
-- never derives one, because two derivations of one fact eventually disagree
-- about a boundary.
--
-- Rows appear by being MENTIONED: the first frame carrying a wipe id this module
-- has not seen creates it. There is no "start a wipe" call and there must not be
-- one, because the website is not present when a wipe happens — a wipe is a fact
-- about a world that was restarted while nobody was watching.
CREATE TABLE IF NOT EXISTS rust_wipes (
server_id VARCHAR(64) NOT NULL,
wipe_id VARCHAR(48) NOT NULL,
save_created_at VARCHAR(32) NULL,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, wipe_id),
CONSTRAINT fk_rust_wipes_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Players ───────────────────────────────────────────────────────────────
--
-- Identity, and deliberately nothing else. It is keyed on the Steam id alone
-- and carries no server: a player is the same person on all six of a community's
-- servers, and everything that is per-server lives in the stats table.
--
-- `user_id` is NOT here. Linking a Steam id to a website account is phase 6's
-- work (R1), and a column waiting for it would be a column every read has to
-- remember is always null.
CREATE TABLE IF NOT EXISTS rust_players (
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
name VARCHAR(191) NULL,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ── The permanent record ──────────────────────────────────────────────────
--
-- One row per player per wipe per server, and the only counters this module
-- keeps. R12's "per-wipe detail plus all-time rollups" is satisfied by SUMming
-- this rather than by maintaining a second all-time row, because two counters
-- for one fact drift the first time an ingest is replayed.
--
-- Every column is a COUNT that only ever goes up within a wipe, which is what
-- makes ingest idempotent-ish in the only way that matters: the cursor advances
-- only after the batch commits, so a crash re-reads a batch it has not counted.
--
-- `playtime_sec` comes from `sessionSec` on a disconnect, and a session whose
-- start this module never saw contributes NOTHING rather than zero — the plugin
-- omits the field, the ingest skips it, and the number stays honestly short
-- instead of quietly wrong.
CREATE TABLE IF NOT EXISTS rust_player_wipe_stats (
server_id VARCHAR(64) NOT NULL,
wipe_id VARCHAR(48) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
kills INT UNSIGNED NOT NULL DEFAULT 0,
deaths INT UNSIGNED NOT NULL DEFAULT 0,
suicides INT UNSIGNED NOT NULL DEFAULT 0,
npc_kills INT UNSIGNED NOT NULL DEFAULT 0,
structures INT UNSIGNED NOT NULL DEFAULT 0,
sessions INT UNSIGNED NOT NULL DEFAULT 0,
playtime_sec BIGINT UNSIGNED NOT NULL DEFAULT 0,
last_seen DATETIME NULL,
PRIMARY KEY (server_id, wipe_id, steam_id),
KEY idx_rust_stats_kills (server_id, wipe_id, kills DESC),
KEY idx_rust_stats_player (steam_id)
);
-- ── What they gathered ────────────────────────────────────────────────────
--
-- A row per resource rather than a JSON blob on the stats row, for one reason:
-- the leaderboard question is "who gathered the most sulfur this wipe", and that
-- is an ORDER BY over a column in every SQL engine and a JSON function call in
-- exactly one. The resource name is the game's own shortname, unknown in advance
-- and not worth a lookup table.
CREATE TABLE IF NOT EXISTS rust_gather_totals (
server_id VARCHAR(64) NOT NULL,
wipe_id VARCHAR(48) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
resource VARCHAR(64) NOT NULL,
amount BIGINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, wipe_id, steam_id, resource),
KEY idx_rust_gather_top (server_id, wipe_id, resource, amount DESC)
);
-- ── The recent raw window ─────────────────────────────────────────────────
--
-- Every ingested event, whole, for as long as the retention sweep keeps it. The
-- killfeed reads this; so does an admin looking at what happened.
--
-- `raw` holds the entire frame and the columns beside it are only what a query
-- needs to reach — the same rule the sidecar's own store follows, one hop along:
-- a protocol version that adds a field needs no migration here.
--
-- **`kind` is a security boundary, not a label.** Some kinds carry IP addresses
-- and player reports (PROTOCOL.md §8.4), and what makes them safe is that the
-- public read is filtered by an allowlist this module holds, default-deny. The
-- rows are stored either way, because an operator chasing ban evasion needs them.
CREATE TABLE IF NOT EXISTS rust_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
wipe_id VARCHAR(48) NULL,
kind VARCHAR(64) NOT NULL,
t BIGINT NOT NULL,
steam_id VARCHAR(32) NULL,
raw LONGTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rust_events_server (server_id, id DESC),
KEY idx_rust_events_kind (server_id, kind, id DESC),
KEY idx_rust_events_wipe (server_id, wipe_id, id DESC),
KEY idx_rust_events_created (created_at)
);
-- ── Who is on right now ───────────────────────────────────────────────────
--
-- Replaced wholesale every time the `players.online` board arrives, which is on
-- every bridge connect and every 60 seconds. It is a BOARD, and the reason it is
-- its own table rather than rows in `rust_events` is that a board answers "now"
-- and an event answers "then"; storing a board as history is the mistake the
-- wire's `type` field exists to prevent, and it would be a shame to make it here
-- after the sidecar went to the trouble of not making it there.
CREATE TABLE IF NOT EXISTS rust_presence (
server_id VARCHAR(64) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
name VARCHAR(191) NULL,
sleeping TINYINT(1) NOT NULL DEFAULT 0,
connected_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, steam_id)
);
-- ── The ingest cursor ─────────────────────────────────────────────────────
--
-- Where this module has read up to in each sidecar's feed. One row per server.
--
-- It is persisted rather than held in memory because the alternative is a module
-- that re-reads everything on every boot or nothing at all, and both are wrong in
-- a way that only shows up in production. The cursor advances **after** the batch
-- is written, never before: a crash mid-batch re-reads rows it has not counted,
-- which is the safe direction to be wrong in.
--
-- A NEW server starts at the sidecar's current end rather than at zero (see
-- `GET /feed` with no `since`). A module installed today against a sidecar that
-- has been running a month wants what happens next — replaying a fortnight of
-- deaths into stats whose wipes it never saw is not a catch-up, it is a
-- fabrication of history it was not present for.
CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
last_event_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
events_seen BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_cursor_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Who owns which Steam account ──────────────────────────────────────────
--
-- R1's identity link, and the reason it is a table rather than a column on
-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed
-- by a Steam id, and it outlives every row this module writes about play. A
-- column here would be null for the overwhelming majority of players and would
-- be deleted by any sweep that pruned inactive ones.
--
-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that
-- call in protocol 2 and it is the truth of the thing: a Steam account is one
-- person across every server an operator runs, where stats are per server and
-- per wipe. Linking on one server links for the fleet, because there is nothing
-- else it could honestly mean.
--
-- **One Steam id, at most one user** — that is what the primary key buys, and it
-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may
-- do what in game and phase 13 makes it the thing that hands out loot; both are
-- grants against a Steam id, and both assume the question "whose is this?" has
-- exactly one answer.
--
-- The reverse is deliberately NOT constrained: one website user may hold several
-- Steam accounts. People have a second account, or a family shares a site login,
-- and refusing that would be inventing a rule the game does not have.
--
-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The
-- alternative is a row naming a user id that resolves to nobody, which every
-- read would then have to defend against.
CREATE TABLE IF NOT EXISTS rust_account_links (
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
user_id INT NOT NULL,
-- What the player was called in game when they linked. A display name, kept
-- so an operator reading the admin panel sees a person rather than a number;
-- never used to identify anybody, because a Rust name changes on a whim.
name VARCHAR(191) NULL,
-- Which server minted the code. Not part of the identity — the link is
-- fleet-wide — but an operator asking "where did this come from" has no other
-- way to find out, and a support conversation starts there.
server_id VARCHAR(64) NULL,
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
KEY idx_rust_links_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
--
-- The website is the author of record for who may do what in game, and the
-- framework's own permission store is an ENFORCEMENT CACHE. That is one
-- sentence with three consequences, and the tables below are shaped by them:
--
-- • Every third-party plugin honours a site grant with no adapter, because
-- they all already call `UserHasPermission`. Nothing here is read by the
-- game directly; it is pushed into the store the game already consults.
-- • A wipe stops being a data-loss event. The game forgets and the site does
-- not, so the next sync puts it all back.
-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
-- the site has to be able to tell a grant it made from one somebody typed
-- at a console — and that is a fact only the site can hold, because the
-- store records who granted a permission nowhere.
--
-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
--
-- Not against a Steam id, though a Steam id is what reaches the game. The site
-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
-- earned them, and an account unlinked from a person takes their privileges
-- with it. The Steam ids are resolved from `rust_account_links` at push time,
-- so a player who links a second account gets what they hold on both — which is
-- the honest reading of "this person may do this".
--
-- A user with no linked account is authored against perfectly well and simply
-- reaches nobody until they link. That is visible on the admin screen rather
-- than silent, because a grant that reaches nothing looks identical to a grant
-- that worked from every other angle.
--
-- ── Scope (D29) ───────────────────────────────────────────────────────────
--
-- Every authored row carries one: a server id, or `*` for the whole fleet. The
-- game stores permissions per server (each has its own store), an operator
-- running a modded server and a vanilla one will not want one set on both, and
-- a single-server community never has to think about it.
-- ── Groups ────────────────────────────────────────────────────────────────
--
-- Mirrored into the game as REAL groups (D30) rather than flattened into
-- per-player grants. Third-party plugins read group membership, BetterChat's
-- group API (R15, phase 17) has something to hang on, and an operator reading
-- `oxide.show groups` sees what the website shows.
--
-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
-- not go away: **a player the store has never seen cannot be put in a group**,
-- while a direct grant to the same id works immediately. The sync reports those
-- members as pending and the membership lands on their first connection.
--
-- The name is the primary key, fleet-wide, even though the row carries a scope:
-- one `vip` on the site is one `vip` in the game, pushed to the servers its
-- scope names. Two groups of the same name with different scopes would be two
-- definitions of one name in every store that received both.
CREATE TABLE IF NOT EXISTS rust_perm_groups (
name VARCHAR(64) NOT NULL PRIMARY KEY,
title VARCHAR(120) NOT NULL DEFAULT '',
rank INT NOT NULL DEFAULT 0,
scope VARCHAR(64) NOT NULL DEFAULT '*',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- What each group carries. A row per permission rather than a list on the group
-- for the ordinary reason: "which groups grant kits.vip" is the question an
-- operator asks when they are about to remove a plugin, and that is a WHERE
-- clause here and a scan of every row in the other shape.
CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
group_name VARCHAR(64) NOT NULL,
permission VARCHAR(128) NOT NULL,
PRIMARY KEY (group_name, permission),
CONSTRAINT fk_rust_perm_group_permissions_group
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
);
-- Who is in each group — by website user, like every other authored row.
--
-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
-- staff member's account being deleted must not delete the record of what they
-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
-- The activity log is the audit trail; this column is a convenience beside it.
CREATE TABLE IF NOT EXISTS rust_perm_group_members (
group_name VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
added_by INT NULL,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_name, user_id),
KEY idx_rust_perm_members_user (user_id),
CONSTRAINT fk_rust_perm_members_group
FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
CONSTRAINT fk_rust_perm_members_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- ── Direct grants ─────────────────────────────────────────────────────────
--
-- A permission held by one person, without a group. It is not a lesser version
-- of membership: it is the shape that reaches a player who has never connected
-- to that server, which is exactly what an entitlement earned on the website at
-- three in the morning has to do (R16).
--
-- `source` is why this table does not need changing in phase 13. Every later
-- author — an event action granting the right to redeem a kit, a lease handing
-- out a weekend group — writes a row here with its own source rather than a
-- store of its own, so there is one answer to "why does this player have this"
-- and one place the push reads.
CREATE TABLE IF NOT EXISTS rust_perm_grants (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
permission VARCHAR(128) NOT NULL,
scope VARCHAR(64) NOT NULL DEFAULT '*',
source VARCHAR(32) NOT NULL DEFAULT 'admin',
note VARCHAR(255) NULL,
granted_by INT NULL,
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope),
KEY idx_rust_perm_grant_user (user_id),
CONSTRAINT fk_rust_perm_grants_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- ── What this site has actually put in each game ──────────────────────────
--
-- The site's memory of its own authorship, one row per thing it has confirmed
-- into one server's store. It is the table that makes D31 possible at all.
--
-- Three sets, and every interesting question is the difference between two of
-- them:
--
-- desired pushed what to apply
-- pushed desired what to RETIRE, because the site put it there and has
-- since withdrawn it
-- present desired drift: somebody else put it there
--
-- Without the middle row a withdrawn grant is indistinguishable from a hand
-- edit, and those two have opposite correct answers. Inferring it from absence
-- is the mistake this table exists to prevent.
--
-- It is keyed by Steam id rather than by user, because it records what is in the
-- GAME, and the game has never heard of a website account. Unlinking an account
-- therefore leaves its row here until the next sync retires it — which is the
-- correct behaviour and would be impossible to express keyed the other way.
CREATE TABLE IF NOT EXISTS rust_perm_pushed (
server_id VARCHAR(64) NOT NULL,
-- `grant` | `member` | `group-permission` | `group`
kind VARCHAR(24) NOT NULL,
-- a Steam id, or a group name
subject VARCHAR(64) NOT NULL,
-- a permission, a group name, or '' for the existence of a group
object VARCHAR(128) NOT NULL,
pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, kind, subject, object),
CONSTRAINT fk_rust_perm_pushed_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Drift ─────────────────────────────────────────────────────────────────
--
-- What a sync found in a server's store that the site did not author, within
-- the namespace the site claims. Rows appear and disappear with the report:
-- this is the CURRENT difference, not a history of differences, and a hand edit
-- that somebody has since removed should stop being on the screen.
--
-- Nothing here is ever removed from the game by the sync itself. An operator
-- typing `oxide.grant` during an incident is drift, not an error, and the two
-- answers offered to them — adopt it, or revoke it — are both a person's
-- decision.
CREATE TABLE IF NOT EXISTS rust_perm_drift (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
kind VARCHAR(24) NOT NULL,
subject VARCHAR(64) NOT NULL,
object VARCHAR(128) NOT NULL,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
CONSTRAINT fk_rust_perm_drift_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Removing something the site never put there ───────────────────────────
--
-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
-- point of a drift row is that it was never pushed. It cannot go through the
-- authored tables either: a foreign grant often names a Steam id that belongs
-- to no website account at all, and there is no user to author it against.
--
-- So a revoke is its own instruction with its own lifetime: queued by a person,
-- carried in the next sync's retire list, and deleted once a report says the
-- game no longer has it. A server that is offline keeps the instruction until
-- it comes back, which is the behaviour an operator expects from a website that
-- claims to be the author of record.
CREATE TABLE IF NOT EXISTS rust_perm_revocations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
kind VARCHAR(24) NOT NULL,
subject VARCHAR(64) NOT NULL,
object VARCHAR(128) NOT NULL,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
CONSTRAINT fk_rust_perm_revocations_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── The state of the mirror, per server ───────────────────────────────────
--
-- One row per configured server: whether its store currently matches what the
-- site authors, when that was last true, and what the last report said.
--
-- `dirty` is how everything that should provoke a sync says so without knowing
-- anything about syncing: an admin writing a grant, a drift hook firing in the
-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
-- every other part of the module owns WHETHER.
--
-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
-- that pushed the whole set every tick would work and would also write to six
-- game servers every thirty seconds for ever; comparing a hash costs one query
-- and skips the round trip when nothing has changed. The periodic audit below
-- is what keeps that from being a way to never notice drift.
CREATE TABLE IF NOT EXISTS rust_perm_sync (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
-- `pending` | `ok` | `failed`
state VARCHAR(24) NOT NULL DEFAULT 'pending',
dirty TINYINT(1) NOT NULL DEFAULT 1,
desired_hash VARCHAR(64) NULL,
synced_hash VARCHAR(64) NULL,
boot_id VARCHAR(64) NULL,
wipe_id VARCHAR(48) NULL,
last_attempt_at DATETIME NULL,
last_ok_at DATETIME NULL,
report LONGTEXT NULL,
error VARCHAR(191) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_perm_sync_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── What each server's plugins have registered ────────────────────────────
--
-- The option source the authoring form offers (D33), cached from the live read
-- so that opening the form is not six round trips to six game hosts.
--
-- It is a cache of a fact that changes when an operator loads a plugin, and it
-- is refreshed on every sync — which is also why a name that has stopped being
-- registered disappears from the form rather than lingering as a choice that
-- silently does nothing.
CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
server_id VARCHAR(64) NOT NULL,
permission VARCHAR(128) NOT NULL,
seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, permission),
CONSTRAINT fk_rust_perm_catalogue_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);
-- ── Changes to tables that already shipped ────────────────────────────────
--
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
-- does nothing against a database that already has the table, so an edited column
-- would reach fresh installs only — which is the worst possible distribution for
-- a schema change, because it works everywhere it is tested.
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL;
-- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the
-- same fact as when the server last said something — and the pages were reading
-- the first as if it were the second, so a server that had been down for three
-- days rendered "last reported just now" on every failed poll.
--
-- They are genuinely two facts and both are wanted: `updated_at` decides whether
-- the row is stale (a module that stopped polling must not leave a page claiming
-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only
-- a successful refresh moves it.
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL;
-- ── Configuration written from the site (phase 7b, R18) ───────────────────
--
-- The audit trail for the most powerful thing this website can do to somebody's
-- game host: write a file on it. One row per save attempt, including the ones
-- that were refused and the ones the plugin rolled back — a write that did not
-- land is exactly the row an operator asking "why is ZoneManager down" needs to
-- find.
--
-- **No file bodies.** `changes` holds the fields that changed and their before
-- and after LITERALS, which is what a person reading this wants, and secrets are
-- redacted on the way in (`configEdit.redactChange`). D37 lets an admin read a
-- credential on the page they opened deliberately; this table is read by more
-- people, for longer, and usually by somebody who was not there.
--
-- The versions bracket the write: `version_before` is what the plugin said the
-- file was when it was read, `version_after` what it is now. They are the
-- plugin's own hashes, echoed — this module never computes one.
CREATE TABLE IF NOT EXISTS rust_config_writes (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
path VARCHAR(255) NOT NULL,
plugin VARCHAR(128) NULL,
-- What the admin asked us to reload. NULL is an honest value: a file whose
-- plugin is not loaded is written and not reloaded, and saying so is the
-- difference between "saved" and "in effect".
reload_target VARCHAR(128) NULL,
-- `form` or `raw`. Which tier an edit came through changes how it should be
-- read: a form edit is type-preserving and narrow, a raw edit replaced the
-- whole document.
tier VARCHAR(16) NOT NULL DEFAULT 'form',
user_id INT NULL,
-- `applied` | `rolled-back` | `refused` | `unreachable`
outcome VARCHAR(24) NOT NULL,
reloaded TINYINT(1) NOT NULL DEFAULT 0,
changes LONGTEXT NULL,
version_before VARCHAR(64) NULL,
version_after VARCHAR(64) NULL,
detail VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_config_writes_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
-- A deleted account must not delete the record that they changed a setting.
-- The row stays and the name goes; the alternative is an audit trail that a
-- person can erase by closing their account.
CONSTRAINT fk_rust_config_writes_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
KEY idx_rust_config_writes_server (server_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Who may see who is online (the presence fix, 2026-09-22) ──────────────
--
-- The org lead's rule: **nothing tells who is online by default.** The Online
-- list, the killfeed, chat and every other frame that says a named player was on
-- the server reach STAFF unless an operator deliberately widens them. A count is
-- not a name and stays public.
--
-- Two places, because the decision has two shapes:
--
-- • `rust_settings` holds the FLEET default — one row per key. A key/value
-- table rather than a column per setting, because phase 9's clan-roster
-- audience is the next key and a table that grows a column per setting grows
-- an ALTER per setting.
-- • `rust_servers.presence_audience` is an optional PER-SERVER override. NULL
-- means "inherit the fleet default", which is not the same as any audience —
-- an operator who later narrows the fleet must narrow every server that never
-- chose otherwise.
--
-- The stored value is a word (`staff` · `signed_in` · `public`) and an unknown
-- word reads as `staff` (`model/visibility`): a typo in a row must narrow, never
-- widen.
CREATE TABLE IF NOT EXISTS rust_settings (
setting_key VARCHAR(64) NOT NULL PRIMARY KEY,
value VARCHAR(255) NOT NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_settings_user
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;
-- ── Clans (phase 9, protocol 6) ───────────────────────────────────────────
--
-- Rust's FIRST-PARTY clans, which this module answers core's Team questions
-- from (R5, PLAN.md §24). Three tables, and the split is the same one the rest
-- of this file makes: what a board said (`rust_clans`, `rust_clan_members`),
-- and what this module knows about the board itself (`rust_clan_boards`).
--
-- **`external_id` is the Team's identity, and it is NOT the game's clan id.**
-- It is `<serverId>:<clanId>:<createdMs>` (D52). The game keeps clans in
-- `clans.<version>.db` with the version hard-coded, so a game update that bumps
-- it starts a fresh file whose ids restart at 1. Keyed on the id alone, the new
-- clan #1 would inherit the old clan #1's Team — its forum, its members-only
-- history — and core would read the swap as a rename.
--
-- **A clan that leaves the board is marked gone, not deleted.** `gone_at` is set
-- only when a board that is COMPLETE for its server no longer carries it: a
-- board truncated at the game's 100-clan ceiling (D55) proves nothing about a
-- clan it does not list. A gone clan is not offered to core, which is what lets
-- core archive its Team.
CREATE TABLE IF NOT EXISTS rust_clans (
external_id VARCHAR(160) NOT NULL PRIMARY KEY,
server_id VARCHAR(64) NOT NULL,
clan_id BIGINT NOT NULL,
created_ms BIGINT NOT NULL,
name VARCHAR(191) NOT NULL,
-- `#rrggbb`, as the plugin spells it. Stored as sent rather than parsed, and
-- re-checked on the way out (`model/clans`), because it ends up in a style.
color VARCHAR(16) NULL,
score BIGINT NOT NULL DEFAULT 0,
member_count INT UNSIGNED NOT NULL DEFAULT 0,
max_members INT UNSIGNED NULL,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gone_at DATETIME NULL,
CONSTRAINT fk_rust_clans_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
KEY idx_rust_clans_server (server_id, gone_at),
KEY idx_rust_clans_game_id (server_id, clan_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per member per clan, replaced whole from each board.
--
-- `rank` is the role's rank, and **rank 1 is leader** — the game's own rule, and
-- several members may hold it. It is NULL when the member's role id matched no
-- role on the board: "not known" must never be read as "leads this clan".
--
-- There is deliberately no `last_seen`. The game has one; the plugin does not
-- send it, because when somebody was last on is presence (PLAN.md §23).
CREATE TABLE IF NOT EXISTS rust_clan_members (
external_id VARCHAR(160) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
name VARCHAR(191) NULL,
role_rank INT NULL,
role_name VARCHAR(64) NULL,
joined_ms BIGINT NULL,
PRIMARY KEY (external_id, steam_id),
CONSTRAINT fk_rust_clan_members_clan
FOREIGN KEY (external_id) REFERENCES rust_clans (external_id) ON DELETE CASCADE,
KEY idx_rust_clan_members_steam (steam_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What this module knows about each server's clan board, as opposed to what the
-- board said.
--
-- **Freshness is judged by THIS side's clock.** `board_t` is the plugin's own
-- timestamp on the board; `seen_at` is when this module first saw that value.
-- A board whose `t` stops advancing is a game that stopped talking, and the
-- age of `seen_at` is how long ago that was — comparing `board_t` to the
-- website's clock instead would let a game host whose clock runs ahead make a
-- stale board look current for as long as the skew lasts.
--
-- `umod_clans` is whether the optional uMod Clans plugin is loaded on that
-- server (D47): its clans are a separate system and never Teams, and the admin
-- page says so.
CREATE TABLE IF NOT EXISTS rust_clan_boards (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
board_t BIGINT NULL,
seen_at DATETIME NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
supported TINYINT(1) NOT NULL DEFAULT 0,
truncated TINYINT(1) NOT NULL DEFAULT 0,
backend VARCHAR(64) NULL,
reason VARCHAR(255) NULL,
umod_clans TINYINT(1) NOT NULL DEFAULT 0,
clan_count INT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_clan_boards_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

134
server/index.js Normal file
View File

@@ -0,0 +1,134 @@
// ── The server entry point ─────────────────────────────────────────────────
//
// Core requires this file once, synchronously, while its own `app.js` is still
// being required, and calls the exported function with `(ctx, api)`. That is the
// entire server-side handshake: everything this module can reach arrives on
// `ctx`, and everything it can offer is registered through `api`.
//
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
//
// ── Three rules, and each one has a failure behind it ──────────────────────
//
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
// with the connection pool pointed at a dead port — the route-manifest
// generator and the OpenAPI generator both do it — so a module that queried
// at registration time would hang both. Anything that needs a live database
// goes in `onBoot`, which runs after the schema is up.
//
// 2. **Never resolve what core owns.** This module lives at
// `<website>/modules/rust/`, outside core's `server/`, so Node's resolver
// never reaches core's `node_modules` and `require('express')` from here
// simply fails. express, express-validator, the database, the logger and the
// middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`. A
// second express in the process would be a second `Router` prototype, exactly
// as a second React would be a second renderer.
//
// 3. **Never reach into core's tree.** No relative path may escape this module's
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
//
// ── Why the requires are INSIDE the function ───────────────────────────────
//
// Every file below reaches core through `./core`, whose members resolve `ctx`
// when they are CALLED. But a router writes `const express = core.express` at its
// own file scope, and that runs the moment the file is required. So
// `core.init(ctx)` has to happen before the first `require` of anything under
// `router/`. Hoisting these to the top of the file breaks the module with an
// error about a missing `ctx`, thrown from a file that never mentions one.
//
// Node caches modules, so requiring here costs nothing after the first call.
const core = require('./core')
/**
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
* @param {object} api what the module registers (§2.4)
*/
module.exports = function register(ctx, api) {
core.init(ctx)
/* eslint-disable global-require */
const publicRust = require('./router/public/rust.router')
const playerRust = require('./router/player/rust.router')
const adminRust = require('./router/admin/rust.router')
const usersRust = require('./router/admin/usersRust.router')
const teamProvider = require('./model/clans/teamProvider')
const boot = require('./boot')
/* eslint-enable global-require */
const log = core.logger()
// One prefix, on each of the three tiers (R14). The keys here must match
// `module.json`'s `mounts` exactly — the loader compares the two and rejects a
// mismatch in EITHER direction, so a route never declared and a prefix declared
// and never registered both fail loudly at boot rather than quietly at runtime.
//
// Each router sits INSIDE its tier router, so it structurally cannot reach
// above its prefix, and the tier's gate is already applied: `public` is behind
// nothing by design, `admin` behind `noindex, isLoggedIn, requireRole(...)` and
// `player` behind `noindex, requireAuth`. Per-route gates go on top; the tier
// gate is never re-implemented.
//
// **Prefixes share ONE namespace with core's own, and the collision probe
// cannot see all of it.** Core answers several public routes mounted at the
// tier root rather than under a prefix — `/status` and `/version` among them —
// and the loader's check cannot find those. `/rust` collides with nothing on
// any of the three tiers, checked against core's mount tables rather than
// assumed.
api.registerRoutes({
public: { '/rust': publicRust },
player: { '/rust': playerRust },
admin: { '/rust': adminRust },
})
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
// declared before the slot is mounted, so core wins any path conflict — it owns
// the user, and this module owns what it can say about one.
//
// **It is declared twice, in two different places, on purpose.** This call is
// the SERVER half and `module.json`'s `extensions` array is held against it by
// the loader. The CLIENT half is `registry.registerExtension(ID,
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
// phase 1 found that the hard way with `site.footer.status`, which is a client
// slot and fails the load outright when named there.
api.registerExtension('admin.users.detail', usersRust)
// Teams (R5, PLAN.md §24). A first-party Rust clan is a Team, and this module
// becomes the deployment's one authoritative source of them. Core asks; the
// provider answers from the clan boards (`model/clans`), and refuses rather
// than guessing whenever no board is current.
//
// **One provider per deployment**, so a site running module-uo as well cannot
// have both — the second registration is a collision core reports against the
// module that made it. That is core's rule and a real constraint on a mixed
// UO + Rust site; it is recorded in §24 rather than worked around here.
api.registerTeamProvider(teamProvider)
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
// that must not serve traffic until it has warmed a cache gets that for free.
// It has no timeout, deliberately: a slow boot delays the listener, which is the
// guarantee rather than a problem to be timed out.
//
// `onShutdown` runs while core's database pool and push dispatcher are still
// open, because flushing through them is the only thing it is for. It gets a
// five-second budget and is abandoned past it.
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
// Everything else this module will register — the event triggers and
// audiences, the engagement seeds, the four event catalogues, the
// notification streams and the slash commands — is deliberately absent. Each
// arrives with the phase that has something real to put in it. A registration
// with nothing behind it is worse than a missing one: a declared trigger
// nothing emits and a declared slot nothing fills are both surfaces an operator
// can configure and then wait on.
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
teams: 'first-party clans',
})
}

324
server/ingest.js Normal file
View File

@@ -0,0 +1,324 @@
// ── Reading a sidecar's feed, and turning it into a record ────────────────
//
// One job: move each server's cursor forward, and apply what it passed.
//
// ── Why a cursor and not a socket ─────────────────────────────────────────
//
// The obvious design is a WebSocket — the sidecar has one, and module-uo takes
// exactly that route for the UO bridge. This module polls a cursor instead, and
// the reason is not laziness about latency.
//
// Core runs on Node 20, where a global `WebSocket` is still behind a flag, so a
// socket means taking `ws` as a runtime dependency — and this module's release
// asserts that it has none (D5: everything it needs arrives on `ctx`, and the
// bundle ships no `node_modules`). That is a cost worth paying for latency, but
// the deciding argument is the other one: **a socket needs a cursor anyway.**
// Whatever a feed misses while a module is restarting has to be caught up from
// somewhere, and the catch-up path is the one that must be right. A socket on
// top of a cursor is two mechanisms where the second is load-bearing; a cursor
// alone is one mechanism that is exercised every few seconds rather than only
// after an outage nobody planned.
//
// What it costs is seconds of latency on a killfeed. What it buys is that the
// path which recovers from a five-hour outage is the same path that ran a moment
// ago.
//
// ── The ordering the whole thing rests on ─────────────────────────────────
//
// **The cursor advances after the batch is written, never before.** A crash
// between the two re-reads events already counted, which inflates a total; a
// crash the other way round loses them silently and for ever. Neither is good and
// they are not equally bad — one is visible and bounded, the other is invisible
// and permanent — so the code is arranged to fail in the visible direction.
const core = require('./core')
const clans = require('./model/clans/clans.model')
const db = require('./model/events/events.db')
const links = require('./model/links/links.model')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
const log = core.logger('ingest')
/** How many events to ask for at once. */
const BATCH = 200
/**
* How many batches one tick will drain before letting the loop breathe.
*
* A module that has been down for a day has thousands of events waiting, and
* draining them in one unbounded loop would hold the tick — and a pool
* connection — for as long as that takes. Bounded, it catches up over several
* ticks and the site stays responsive while it does.
*/
const MAX_BATCHES_PER_TICK = 10
/**
* Applies one feed item.
*
* Every frame is stored raw, and only some of them move a counter. That split is
* deliberate: the raw row is what an admin reads and what a later phase can
* re-derive from, and the counters are what a leaderboard sums. A kind this
* build has never heard of still lands in `rust_events` — it costs nothing and
* the alternative is losing the one copy of an event the next version will know
* how to read.
*/
async function apply(serverId, item) {
const frame = (item && item.frame) || {}
const kind = item.kind || frame.kind
const wipeId = frame.wipeId || null
// A wipe exists because something mentioned it. There is no "a wipe started"
// call and there must not be one: the website is not there when a wipe happens.
await db.touchWipe(serverId, wipeId, frame.saveCreatedAt || null)
await db.insertEvent({
serverId,
wipeId,
kind,
t: Number(frame.t) || item.t || Date.now(),
steamId: frame.steamId || null,
raw: frame,
})
const at = { serverId, wipeId, steamId: frame.steamId }
switch (kind) {
case 'player.connected':
await db.touchPlayer(frame.steamId, frame.name || null)
break
case 'player.disconnected': {
await db.touchPlayer(frame.steamId, frame.name || null)
// `sessionSec` is ABSENT when the plugin never saw the connect — a player
// already on the server when it loaded. Absent is not zero: adding a zero
// would be recording a session of no length, which is a different claim
// from recording no session, and it is the one that quietly under-reports
// playtime for ever.
const seconds = Number(frame.sessionSec)
await db.addStats(at, {
sessions: Number.isFinite(seconds) ? 1 : 0,
playtimeSec: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
})
break
}
case 'player.death': {
await db.touchPlayer(frame.steamId, frame.name || null)
// A suicide is a death AND a suicide, not one instead of the other: the
// deaths column is "how many times did this player die", and a leaderboard
// that silently omitted self-inflicted ones would disagree with the
// killfeed sitting next to it on the same page.
await db.addStats(at, { deaths: 1, suicides: frame.attackerType === 'self' ? 1 : 0 })
// Only a real player's kill counts. `npc` and `environment` have no
// attacker to credit, and `self` must not credit the victim with a kill —
// which is the one line here that would look right in review and produce a
// leaderboard topped by whoever died the most.
if (frame.attackerType === 'player' && frame.attackerId) {
await db.touchPlayer(frame.attackerId, frame.attackerName || null)
await db.addStats({ ...at, steamId: frame.attackerId }, { kills: 1 })
}
break
}
case 'player.tally': {
await db.touchPlayer(frame.steamId, frame.name || null)
await db.addStats(at, {
npcKills: Number(frame.npcKills) || 0,
structures: Number(frame.structures) || 0,
})
// A tally is a DELTA since the last flush, which is what makes adding it
// correct. If it ever becomes a running total this loop doubles every
// number in it, slowly, and looks right the whole time.
const gathered = frame.gathered || {}
for (const [resource, amount] of Object.entries(gathered)) {
await db.addGathered(at, resource, Number(amount) || 0)
}
break
}
case 'player.chat':
case 'player.respawned':
await db.touchPlayer(frame.steamId, frame.name || null)
break
// ── Protocol 3: the one frame that changes something other than a counter ──
//
// `/unlink` in game severs the site's link, and it is the only way out of a
// link on the wrong account: the site REFUSES to move a Steam id another
// website account already holds (D23), so without this a player who linked
// while signed in as the wrong account would need staff.
//
// It arrives here rather than through a route because the plugin has nothing
// to delete — the site is the author of record and the game holds no link —
// so `/unlink` is the game reporting what the player asked for, applied off
// the feed like every other frame.
//
// **The authority is the Steam account itself.** Whoever is connected to the
// game as it is who it is, which is a stronger proof of ownership than the
// site can obtain any other way, so this is not scoped by website user.
case 'account.unlinked':
await db.touchPlayer(frame.steamId, frame.name || null)
await links.unlinkFromGame(frame.steamId)
break
// Stored and counted as a sighting, nothing more. The code is deliberately
// NOT on this frame — it travels through the player — so there is nothing
// here to redeem and no pending state for the site to hold. It exists so an
// operator can see linking being used at all.
case 'account.link.requested':
await db.touchPlayer(frame.steamId, frame.name || null)
break
// ── Protocol 4: somebody changed the permission store, and it was not us ──
//
// The plugin raises this only for writes it did not make itself — its own
// sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What
// arrives here is therefore a hand edit, a console command, or another
// plugin granting something.
//
// **It is a reason to reconcile, not the reconciliation.** This frame cannot
// say whether the change is foreign: only the desired set can, and that
// comparison happens in the sync. So the server is marked dirty and the next
// tick produces the authoritative answer — which means a hook that stops
// firing on a framework upgrade costs latency and nothing else. The audit
// interval finds the same drift within fifteen minutes either way.
case 'perm.drift':
await permissionsDb.markDirty(serverId)
break
// ── Protocol 6: first-party clans ──────────────────────────────────────
//
// Each one is told to core as it happens (`ctx.teams.publish`) and written
// to the clan's Team feed as a members-only line (D49). Neither is the
// record: the `clans` board the plugin re-sends a few seconds later is what
// the store is rebuilt from, so an event this module never saw costs a
// feed line and nothing else.
//
// No `touchPlayer` here, on purpose: it moves `last_seen`, and a kick is
// done TO somebody who may be offline. `model/clans` notes names without it.
case 'clan.created':
case 'clan.disbanded':
case 'clan.member.added':
case 'clan.member.left':
case 'clan.member.kicked':
await clans.applyEvent(serverId, frame)
break
default:
// Stored, not counted. Moderation frames, the server lifecycle, and
// anything a newer protocol sends that this build does not understand.
break
}
}
/**
* Brings one server's cursor up to date.
*
* Returns the number of events applied, for the log and for the tests.
*/
async function ingestServer(server) {
const cursor = await db.getCursor(server.id)
// A server this module has never ingested starts at the sidecar's CURRENT end,
// not at zero. A module installed today against a sidecar that has been running
// for a month should read what happens next — replaying a fortnight of deaths
// into stats for wipes it never saw is not a catch-up, it is inventing a
// history it was not present for. `/feed` with no `since` asks exactly that
// question, which is why the sidecar answers it that way.
if (!cursor) {
const tail = await sidecar.feedTail(server)
if (!tail.ok || !tail.data) {
// Unreachable. Write nothing: a cursor of 0 written now would replay the
// whole retained history the moment the sidecar came back.
return 0
}
await db.setCursor(server.id, Number(tail.data.lastId) || 0, 0)
log.info('cursor started at the feed tail', { server: server.id, at: tail.data.lastId })
return 0
}
let since = Number(cursor.lastEventId) || 0
let applied = 0
for (let batch = 0; batch < MAX_BATCHES_PER_TICK; batch += 1) {
const res = await sidecar.feed(server, since, BATCH)
if (!res.ok || !res.data) return applied
const items = Array.isArray(res.data.items) ? res.data.items : []
for (const item of items) {
try {
await apply(server.id, item)
applied += 1
} catch (err) {
// One malformed event must not wedge a server's cursor for ever. It is
// logged with its id so it can be found, and the cursor moves past it:
// the alternative is an ingest that stops at a single bad row and then
// silently stops being a feed at all.
log.warn('could not apply an event', {
server: server.id,
id: item && item.id,
kind: item && item.kind,
error: err.message,
})
}
}
const lastId = Number(res.data.lastId)
if (Number.isFinite(lastId) && lastId > since) {
// AFTER the batch. See the header.
await db.setCursor(server.id, lastId, items.length)
since = lastId
}
if (!res.data.more) break
}
if (applied > 0) log.info('ingested', { server: server.id, events: applied, cursor: since })
return applied
}
/**
* Applies the boards: what is true right now, rather than what happened.
*
* `players.online` replaces the presence rows wholesale, because that is what a
* board is. Storing it as history is the mistake the wire's `type` field exists
* to prevent, and it would be a poor return for the sidecar's trouble to make it
* here after it went out of its way not to make it there.
*/
async function applyBoards(serverId, boards) {
const presence = boards && boards['players.online']
if (presence && Array.isArray(presence.players)) {
await db.replacePresence(serverId, presence.players)
}
// Clans only once the game has spoken at all. A sidecar that has never heard
// from its plugin holds no boards, and recording "no clan board" then would
// blame the plugin's protocol for a game server that is simply not up. Left
// alone, the stored board ages past fresh on its own, which is the true answer.
if (boards && boards['server.hello']) {
// Fenced: clans are the one board here that core's Teams depend on, and a
// failure applying them must cost the clans rather than the presence board
// above or the server state the caller writes next.
try {
await clans.applyBoard(serverId, boards.clans)
await clans.reofferActivity(serverId)
} catch (err) {
log.warn('could not apply the clan board', { server: serverId, error: err.message })
}
}
}
module.exports = { apply, applyBoards, ingestServer, BATCH, MAX_BATCHES_PER_TICK }

View File

@@ -0,0 +1,298 @@
// ── SQL for first-party clans ─────────────────────────────────────────────
//
// Three tables (see `schema.sql`): the clans a board carried, their members, and
// what this module knows about each server's board. Raw parameterised SQL, as
// everywhere in this module; the model decides what any of it means.
const core = require('../../core')
const CLANS = 'rust_clans'
const MEMBERS = 'rust_clan_members'
const BOARDS = 'rust_clan_boards'
const LINKS = 'rust_account_links'
const PLAYERS = 'rust_players'
const SERVERS = 'rust_servers'
// ── Boards ─────────────────────────────────────────────────────────────────
/** One server's board record, or null when it has never sent one. */
async function getBoard(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported,
truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount
FROM ${BOARDS} WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/** Every configured server beside its board record, which may be absent. */
async function listBoards() {
return core.query(
`SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled,
b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated,
b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount
FROM ${SERVERS} s
LEFT JOIN ${BOARDS} b ON b.server_id = s.id
ORDER BY s.sort_order ASC, s.id ASC`,
)
}
/**
* Records what a board said about itself.
*
* `seenAt` is passed only when the board's `t` ADVANCED, and is then the
* website's own now; otherwise the stored one is kept. That is the whole of the
* freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to
* every caller to read-then-write.
*/
async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) {
await core.query(
`INSERT INTO ${BOARDS}
(server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at)
VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
board_t = VALUES(board_t),
seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'},
enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated),
backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans),
clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`,
[
serverId,
boardT,
enabled ? 1 : 0,
supported ? 1 : 0,
truncated ? 1 : 0,
backend || null,
reason ? String(reason).slice(0, 255) : null,
umodClans ? 1 : 0,
clanCount || 0,
],
)
}
// ── Clans ──────────────────────────────────────────────────────────────────
/** Every clan this module holds for one server, gone or not. */
async function listClansForServer(serverId) {
return core.query(
`SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name,
member_count AS memberCount, gone_at AS goneAt
FROM ${CLANS} WHERE server_id = ?`,
[serverId],
)
}
/**
* Every member of one server's current clans, as the board last stated them,
* for diffing the next board against. The name is the BOARD's, not the player
* table's, because it is compared with the board.
*/
async function listMembersForServer(serverId) {
return core.query(
`SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank,
m.role_name AS role, m.name
FROM ${MEMBERS} m
JOIN ${CLANS} c ON c.external_id = m.external_id
WHERE c.server_id = ? AND c.gone_at IS NULL`,
[serverId],
)
}
async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) {
await core.query(
`INSERT INTO ${CLANS}
(external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members,
first_seen, updated_at, gone_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
ON DUPLICATE KEY UPDATE
name = VALUES(name), color = VALUES(color), score = VALUES(score),
member_count = VALUES(member_count), max_members = VALUES(max_members),
updated_at = CURRENT_TIMESTAMP, gone_at = NULL`,
[externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers],
)
}
/**
* Replaces one clan's members.
*
* Delete then insert, not wrapped in a transaction — the same trade the presence
* board makes (`events.db.replacePresence`): a fraction of a second in which a
* roster read might come back short, against holding a lock on a table that core's
* reconciler and two public routes read.
*/
async function replaceMembers(externalId, members) {
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId])
for (const m of members) {
// eslint-disable-next-line no-await-in-loop
await core.query(
`INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank),
role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`,
[externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs],
)
}
}
/** Marks clans gone. Their members are removed with them; a gone clan has no roster. */
async function markGone(externalIds) {
if (!externalIds.length) return
const marks = externalIds.map(() => '?').join(', ')
await core.query(
`UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`,
externalIds,
)
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds)
}
/** One clan by its Team identity, with its server's name, or null. */
async function findClan(externalId) {
const rows = await core.query(
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score,
c.member_count AS memberCount, c.max_members AS maxMembers,
c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt
FROM ${CLANS} c
JOIN ${SERVERS} s ON s.id = c.server_id
WHERE c.external_id = ?`,
[externalId],
)
return rows[0] || null
}
/**
* The newest clan this module holds under a game id on one server, or null.
*
* The fallback for the one event that can arrive without a creation time
* (`clan.member.added`, when the plugin could not read the clan back). Newest,
* because an id that the game has re-used belongs to the clan that re-used it.
*/
async function findByGameId(serverId, clanId) {
const rows = await core.query(
`SELECT external_id AS externalId, name
FROM ${CLANS} WHERE server_id = ? AND clan_id = ?
ORDER BY created_ms DESC LIMIT 1`,
[serverId, clanId],
)
return rows[0] || null
}
/** Every clan still on a board, for core's `getTeams`. */
async function listActiveClans() {
return core.query(
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
c.name, c.color, c.score, c.member_count AS memberCount
FROM ${CLANS} c
JOIN ${SERVERS} s ON s.id = c.server_id
WHERE c.gone_at IS NULL
ORDER BY c.server_id ASC, c.score DESC, c.name ASC`,
)
}
/** One server's clans still on its board, for the public Clans tab. Best first. */
async function listPublicForServer(serverId) {
return core.query(
`SELECT external_id AS externalId, name, color, score, member_count AS memberCount,
max_members AS maxMembers
FROM ${CLANS}
WHERE server_id = ? AND gone_at IS NULL
ORDER BY score DESC, name ASC`,
[serverId],
)
}
/**
* One clan's roster, with the website account behind each member when there is
* one and whether they are on the clan's server right now.
*
* Three joins, all of this module's own tables: the link (a Steam id to a user),
* the player table (the newest name the game has sent for them) and the presence
* board. Presence is joined on the CLAN's server — a member on another server of
* the fleet is not online here.
*/
async function listMembers(externalId) {
return core.query(
`SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank,
m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId,
(pr.steam_id IS NOT NULL) AS online
FROM ${MEMBERS} m
JOIN ${CLANS} c ON c.external_id = m.external_id
LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id
LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id
LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id
WHERE m.external_id = ?
ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`,
[externalId],
)
}
/** Whether a website user holds a linked Steam account that is a member of this clan. */
async function userIsMember(externalId, userId) {
const rows = await core.query(
`SELECT 1 AS yes
FROM ${MEMBERS} m
JOIN ${LINKS} l ON l.steam_id = m.steam_id
WHERE m.external_id = ? AND l.user_id = ?
LIMIT 1`,
[externalId, userId],
)
return rows.length > 0
}
/**
* Recent clan events for one server, oldest first, for re-offering their feed
* items to core until the Team they name exists (see `model/clans`).
*/
async function recentClanEvents(serverId, sinceMs) {
return core.query(
`SELECT id, kind, t, raw
FROM rust_events
WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ?
ORDER BY t ASC, id ASC
LIMIT 200`,
[serverId, sinceMs],
)
}
/**
* Notes a player's name WITHOUT touching `last_seen`.
*
* `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that
* says a player was on and wrong for a clan frame: a kick is done TO somebody who
* may be offline, and a leaderboard's "last seen" would then read as a presence
* signal for a player who never connected (PLAN.md §23).
*
* A player this module has never heard of still gets a on the new row,
* because the column is NOT NULL; what matters is that an existing row's is left
* alone, and every surface that reads it is behind the presence gate anyway.
*/
async function rememberName(steamId, name) {
if (!steamId) return
await core.query(
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`,
[steamId, name || null],
)
}
module.exports = {
getBoard,
listBoards,
putBoard,
listClansForServer,
listMembersForServer,
upsertClan,
replaceMembers,
markGone,
findClan,
findByGameId,
listActiveClans,
listPublicForServer,
listMembers,
userIsMember,
recentClanEvents,
rememberName,
}

View File

@@ -0,0 +1,573 @@
// ── First-party clans: the board, the events, and who may see a roster ────
//
// Rust's OWN clan system, which this module turns into core's Teams (R5,
// PLAN.md §24). Three jobs, one file, because all three have to agree on what a
// clan's identity is:
//
// applyBoard a `clans` snapshot → the store, plus what changed
// applyEvent a `clan.*` event → core (publish) and the Team feed
// canSeeRoster D48's audience, for core's `projectRoster` and our own page
//
// ── The identity (D52) ────────────────────────────────────────────────────
//
// `<serverId>:<clanId>:<createdMs>`. The game's clan id alone is not one: its
// database file carries a hard-coded version, so a game update that bumps it
// starts a fresh file and ids restart at 1. Keyed on the id, the new clan #1
// would inherit the old clan #1's Team, forum and history.
//
// ── What a board may conclude, and what it may not ───────────────────────
//
// A board is authoritative for the clans it CARRIES. It is authoritative about
// the clans it does NOT carry only when it is complete: a board truncated at the
// game's 100-clan ceiling (D55), or one with a row this build could not read,
// proves nothing about a clan it leaves out, and marking that clan gone would
// hand core an archive on no evidence.
const crypto = require('node:crypto')
const core = require('../../core')
const db = require('./clans.db')
const visibility = require('../visibility/visibility.model')
const log = core.logger('clans')
/**
* How long a board may go without its `t` advancing and still count as current.
*
* The plugin re-sends it every 60 seconds and this module reads it every 30, so
* three minutes tolerates two missed boards before a server stops vouching for
* its clans.
*/
const FRESH_MS = 3 * 60 * 1000
/**
* How far back a clan event's feed item is offered to core again.
*
* Core writes an item only for a Team it already holds, and a clan founded a
* moment ago is not one yet: its Team appears on core's next reconcile, which is
* debounced by up to 30 seconds. So the "founded" line — the first line of every
* clan's feed — would always be dropped if it were offered once. It is offered
* on every board refresh for this long instead, and core's dedupe key makes every
* offer after the first that lands a no-op.
*/
const REOFFER_MS = 10 * 60 * 1000
/** Team kinds core's `publish` takes, by the clan event that produces them. */
const PUBLISH = Object.freeze({
'clan.created': 'team.created',
'clan.disbanded': 'team.disbanded',
'clan.member.added': 'team.member.added',
'clan.member.left': 'team.member.removed',
'clan.member.kicked': 'team.member.removed',
})
/**
* The feed items D49 allows: membership, and nothing else. Every one is
* members-only. A disband is not here — it was not one of the four the org lead
* chose, and the Team it would be written to is about to be archived anyway.
*/
const ACTIVITY = Object.freeze({
'clan.created': 'rust.clan.founded',
'clan.member.added': 'rust.clan.joined',
'clan.member.left': 'rust.clan.left',
'clan.member.kicked': 'rust.clan.removed',
})
const CLAN_KINDS = Object.freeze(Object.keys(PUBLISH))
const STEAM_ID = /^\d{1,32}$/
const COLOR = /^#[0-9a-f]{6}$/i
/** The Team identity (D52). */
function externalIdOf(serverId, clanId, createdMs) {
return `${serverId}:${clanId}:${createdMs}`
}
const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null)
const int = (value) => (Number.isInteger(Number(value)) && value !== null && value !== '' ? Number(value) : null)
/**
* One board row as this module stores it, or null when it cannot be read.
*
* A member whose Steam id is not a Steam id is dropped rather than failing the
* clan: the roster is still true about everybody else. A clan with no id, no
* creation time or no name fails as a whole, because it has no identity to
* store it under.
*/
function normaliseClan(serverId, raw) {
if (!raw || typeof raw !== 'object') return null
const clanId = int(raw.clanId)
const createdMs = int(raw.createdMs)
const name = text(raw.name, 191)
if (clanId == null || createdMs == null || createdMs <= 0 || !name) return null
const members = []
for (const m of Array.isArray(raw.members) ? raw.members : []) {
const steamId = m && typeof m.steamId === 'string' && STEAM_ID.test(m.steamId) ? m.steamId : null
if (!steamId) continue
members.push({
steamId,
name: text(m.name, 191),
rank: int(m.rank),
role: text(m.role, 64),
joinedMs: int(m.joinedMs),
})
}
return {
externalId: externalIdOf(serverId, clanId, createdMs),
serverId,
clanId,
createdMs,
name,
color: typeof raw.color === 'string' && COLOR.test(raw.color) ? raw.color.toLowerCase() : null,
score: int(raw.score) || 0,
maxMembers: int(raw.maxMembers),
memberCount: members.length,
members,
}
}
/** A member signature, so an unchanged roster is not rewritten every minute. */
const signature = (members) =>
members
.map((m) => `${m.steamId}|${m.rank == null ? '' : m.rank}|${m.role || ''}|${m.name || ''}`)
.sort()
.join('\n')
const leadersOf = (members) => new Set(members.filter((m) => Number(m.rank) === 1).map((m) => m.steamId))
/**
* Tells core something, and never lets core's answer become this module's
* problem. Both calls are fire-and-forget by contract; the catch is for a core
* that throws synchronously all the same.
*/
function publish(event) {
try {
Promise.resolve(core.teams.publish(event)).catch((err) => {
log.warn('teams publish failed', { kind: event.kind, externalId: event.externalId, error: err.message })
})
} catch (err) {
log.warn('teams publish threw', { kind: event.kind, externalId: event.externalId, error: err.message })
}
}
function requestReconcile(reason) {
try {
core.teams.reconcile({ reason })
} catch (err) {
log.warn('teams reconcile request threw', { reason, error: err.message })
}
}
function pushActivity(items) {
if (!items.length) return
try {
Promise.resolve(core.teams.pushActivity(items)).catch((err) => {
log.warn('teams activity push failed', { items: items.length, error: err.message })
})
} catch (err) {
log.warn('teams activity push threw', { items: items.length, error: err.message })
}
}
// ── The board ──────────────────────────────────────────────────────────────
/**
* Applies one server's `clans` board.
*
* `board` is undefined when the sidecar holds none — a plugin older than
* protocol 6, or one that has not connected since it was upgraded. That is
* recorded as unsupported, and the clans already stored are left exactly as they
* are: a missing board is the absence of an answer, not an answer of absence.
*
* Returns what happened, for the log and the tests.
*/
async function applyBoard(serverId, board) {
if (!board || typeof board !== 'object') {
await db.putBoard({
serverId,
boardT: null,
advanced: false,
enabled: true,
supported: false,
truncated: false,
backend: null,
reason: "this server has not sent a clan board; its plugin may predate protocol 6",
umodClans: false,
clanCount: 0,
})
return { applied: false, reason: 'no board' }
}
const previous = await db.getBoard(serverId)
const boardT = Number(board.t)
const known = previous && previous.boardT != null ? Number(previous.boardT) : null
const advanced = Number.isFinite(boardT) && (known == null || boardT > known)
const supported = board.supported === true
const raw = supported && Array.isArray(board.clans) ? board.clans : null
const clans = []
let unreadable = 0
for (const row of raw || []) {
const clan = normaliseClan(serverId, row)
if (clan) clans.push(clan)
else unreadable += 1
}
// A row this build could not read is treated like the ceiling: the board no
// longer vouches for what it leaves out.
const truncated = board.truncated === true || unreadable > 0
await db.putBoard({
serverId,
boardT: Number.isFinite(boardT) ? boardT : null,
advanced,
enabled: board.enabled !== false,
supported,
truncated,
backend: text(board.backend, 64),
reason: supported ? null : text(board.reason, 255) || 'the plugin could not read this server\'s clans',
umodClans: board.umodClans === true,
clanCount: clans.length,
})
if (unreadable) log.warn('clan board carried rows this build could not read', { server: serverId, unreadable })
// A board whose `t` has not moved is the one already applied. Re-applying it
// would rewrite every roster every 30 seconds to say what it already says.
if (!advanced || !raw) return { applied: false, reason: advanced ? 'unsupported' : 'unchanged' }
const [before, beforeMembers] = await Promise.all([
db.listClansForServer(serverId),
db.listMembersForServer(serverId),
])
const wasActive = new Map(before.filter((c) => !c.goneAt).map((c) => [c.externalId, c]))
const rosterBefore = new Map()
for (const m of beforeMembers) {
if (!rosterBefore.has(m.externalId)) rosterBefore.set(m.externalId, [])
rosterBefore.get(m.externalId).push(m)
}
let created = 0
let rosterChanged = 0
const leaderEvents = []
for (const clan of clans) {
// eslint-disable-next-line no-await-in-loop
await db.upsertClan(clan)
const old = rosterBefore.get(clan.externalId) || []
if (!wasActive.has(clan.externalId)) created += 1
if (signature(old) !== signature(clan.members)) {
// eslint-disable-next-line no-await-in-loop
await db.replaceMembers(clan.externalId, clan.members)
rosterChanged += 1
}
// Leadership is only ever learned here (D54): the game raises no hook when
// somebody is promoted. Published only for a clan that was already on the
// previous board — a brand-new clan's leaders reach core with the Team.
if (wasActive.has(clan.externalId)) {
const was = leadersOf(old)
const now = leadersOf(clan.members)
for (const key of now) if (!was.has(key)) leaderEvents.push({ kind: 'team.leader.added', externalId: clan.externalId, memberKey: key })
for (const key of was) if (!now.has(key)) leaderEvents.push({ kind: 'team.leader.removed', externalId: clan.externalId, memberKey: key })
}
}
// Only a complete board may say a clan is gone.
const onBoard = new Set(clans.map((c) => c.externalId))
const gone = truncated ? [] : [...wasActive.keys()].filter((id) => !onBoard.has(id))
await db.markGone(gone)
for (const event of leaderEvents) publish(event)
if (created || gone.length || rosterChanged) {
requestReconcile('rust clans board changed')
}
if (created || gone.length || rosterChanged || leaderEvents.length) {
log.info('clan board applied', {
server: serverId, clans: clans.length, created, gone: gone.length, rosterChanged,
leaderChanges: leaderEvents.length, truncated,
})
}
return { applied: true, clans: clans.length, created, gone: gone.length, rosterChanged, leaderChanges: leaderEvents.length }
}
// ── The events ─────────────────────────────────────────────────────────────
const nameOr = (name) => name || 'A player'
/** The feed line for one clan event, as core stores it verbatim. */
function summaryOf(kind, frame) {
switch (kind) {
case 'clan.created':
return `${nameOr(frame.name)} founded the clan.`
case 'clan.member.added':
return `${nameOr(frame.name)} joined the clan.`
case 'clan.member.left':
return `${nameOr(frame.name)} left the clan.`
case 'clan.member.kicked':
return frame.byName
? `${nameOr(frame.name)} was removed from the clan by ${frame.byName}.`
: `${nameOr(frame.name)} was removed from the clan.`
default:
return null
}
}
/**
* A key core can dedupe on, from the frame's own content.
*
* Content rather than this module's event row id, so that the same frame read
* twice — a cursor replayed after a crash, or the re-offer below — is the same
* item. **Hashed, because core clamps a dedupe key to 40 characters**, and a
* readable key long enough to be unique (server, clan, creation time, kind,
* player, instant) would be cut short into collisions without a word.
*/
function dedupeKeyOf(serverId, kind, frame) {
const parts = [serverId, frame.clanId, frame.createdMs, kind, frame.steamId || '', frame.t]
return crypto.createHash('sha1').update(parts.join('|')).digest('hex')
}
/** One clan event as a Team feed item, or null when D49 does not allow it. */
function activityItem(serverId, externalId, kind, frame) {
const itemKind = ACTIVITY[kind]
const summary = itemKind && summaryOf(kind, frame)
if (!summary) return null
const t = Number(frame.t)
return {
externalId,
kind: itemKind,
summary,
occurredAt: Number.isFinite(t) ? t : Date.now(),
visibility: 'members',
actorMemberKey: kind === 'clan.member.kicked' ? frame.bySteamId || null : frame.steamId || null,
payload: { serverId, steamId: frame.steamId || null },
dedupeKey: dedupeKeyOf(serverId, kind, frame),
}
}
/** The Team identity a clan event names, or null when it cannot be worked out. */
async function resolveExternalId(serverId, frame) {
const clanId = int(frame.clanId)
const createdMs = int(frame.createdMs)
if (clanId == null) return null
if (createdMs != null && createdMs > 0) return externalIdOf(serverId, clanId, createdMs)
// `clan.member.added` can arrive without a creation time when the plugin could
// not read the clan back. Matched on the game id, newest first.
const known = await db.findByGameId(serverId, clanId)
return known ? known.externalId : null
}
/**
* Applies one `clan.*` event: tells core, and writes the Team feed.
*
* Called from ingest, after the raw frame is stored. The board that follows
* every one of these (the plugin re-sends it a few seconds later) is what the
* store is rebuilt from; this only makes the change visible sooner and records
* the line for the feed.
*/
async function applyEvent(serverId, frame) {
const kind = frame && frame.kind
if (!PUBLISH[kind]) return { applied: false }
if (frame.steamId) await db.rememberName(frame.steamId, text(frame.name, 191))
if (frame.bySteamId) await db.rememberName(frame.bySteamId, text(frame.byName, 191))
const externalId = await resolveExternalId(serverId, frame)
if (!externalId) {
log.info('clan event names a clan this module has never seen', { server: serverId, kind, clanId: frame.clanId })
return { applied: false }
}
// The game said it: this clan is gone. Recorded here as well as by the next
// board, because a board truncated at the ceiling would never say so.
if (kind === 'clan.disbanded') await db.markGone([externalId])
const event = { kind: PUBLISH[kind], externalId }
if (event.kind.startsWith('team.member.')) {
if (!frame.steamId) return { applied: false }
event.memberKey = String(frame.steamId)
}
publish(event)
const item = activityItem(serverId, externalId, kind, frame)
if (item) pushActivity([item])
return { applied: true, externalId }
}
/**
* Offers the last few minutes of one server's clan feed items to core again.
*
* See `REOFFER_MS`. Called after each board refresh; idempotent by construction.
*/
async function reofferActivity(serverId, now = Date.now()) {
const rows = await db.recentClanEvents(serverId, now - REOFFER_MS)
const items = []
for (const row of rows) {
let frame
try {
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw
} catch (err) {
continue
}
if (!frame || !ACTIVITY[frame.kind]) continue
// eslint-disable-next-line no-await-in-loop
const externalId = await resolveExternalId(serverId, frame)
const item = externalId && activityItem(serverId, externalId, frame.kind, frame)
if (item) items.push(item)
}
pushActivity(items)
return items.length
}
// ── Who may see a roster (D48) ─────────────────────────────────────────────
/**
* May this viewer see this clan's roster?
*
* `viewer` is `{ userId, role }` or null — the shape core hands `projectRoster`,
* so core's roster and this module's page decide it with one function.
*
* The viewer's standing is re-read from the `users` row, never taken from what
* the caller says, for the same reason the presence gate does it: a moderator
* demoted this morning, or an account banned, must lose the roster on the next
* request. Everything that cannot be answered answers no.
*/
async function canSeeRoster(viewer, externalId) {
const audience = await visibility.clanRosterAudience()
if (audience === 'public') return true
if (!viewer || viewer.userId == null) return false
const user = await core.users.getById(viewer.userId)
if (!user || (user.status && user.status !== 'active')) return false
if (audience === 'signed_in') return true
if (user.role === 'admin' || user.role === 'moderator') return true
return db.userIsMember(externalId, user.id)
}
// ── The public reads ───────────────────────────────────────────────────────
const shapeBoard = (board, now = Date.now()) => {
if (!board || board.supported == null) {
return { supported: false, fresh: false, truncated: false, enabled: true, reason: 'this server has not sent a clan board yet' }
}
const seenAt = board.seenAt ? new Date(board.seenAt).getTime() : null
return {
supported: Boolean(board.supported),
enabled: Boolean(board.enabled),
truncated: Boolean(board.truncated),
fresh: Boolean(board.supported) && seenAt != null && now - seenAt < FRESH_MS,
reason: board.reason || null,
}
}
/** The Clans tab (D58): every clan on one server's board, best first. Public. */
async function listForServer(serverId, now = Date.now()) {
const [clans, board] = await Promise.all([db.listPublicForServer(serverId), db.getBoard(serverId)])
return {
clans: clans.map((c) => ({
externalId: c.externalId,
name: c.name,
color: c.color || null,
score: Number(c.score) || 0,
memberCount: Number(c.memberCount) || 0,
maxMembers: c.maxMembers == null ? null : Number(c.maxMembers),
})),
board: shapeBoard(board, now),
}
}
/**
* One clan, and its roster if the viewer may see it.
*
* The roster carries no Steam id and no website account id — the same two fields
* core withholds from every public roster. `online` is inside the audience by
* construction (D48): a viewer who may not see the roster sees no names at all.
*/
async function getForViewer(externalId, viewer) {
const clan = await db.findClan(externalId)
if (!clan) return null
const allowed = await canSeeRoster(viewer, externalId)
const audience = await visibility.clanRosterAudience()
const members = allowed && !clan.goneAt ? await db.listMembers(externalId) : []
return {
clan: {
externalId: clan.externalId,
name: clan.name,
color: clan.color || null,
score: Number(clan.score) || 0,
memberCount: Number(clan.memberCount) || 0,
maxMembers: clan.maxMembers == null ? null : Number(clan.maxMembers),
serverId: clan.serverId,
serverName: clan.serverName,
founded: Number(clan.createdMs) || null,
gone: Boolean(clan.goneAt),
},
roster: {
visible: allowed,
audience,
members: members.map((m) => ({
name: m.name || null,
role: m.role || null,
leader: Number(m.rank) === 1,
online: Boolean(Number(m.online)),
joined: m.joinedMs == null ? null : Number(m.joinedMs),
})),
},
}
}
/**
* Every configured server's clan board as the admin page shows it: whether it is
* current, whether it is at the ceiling (D55), why it cannot be read, and
* whether the uMod Clans plugin is loaded there (D47) — whose clans are a
* separate system and never Teams.
*/
async function boardsForAdmin(now = Date.now()) {
const rows = await db.listBoards()
return rows.map((row) => ({
id: row.serverId,
name: row.serverName,
...shapeBoard(row.supported == null ? null : row, now),
clans: Number(row.clanCount) || 0,
umodClans: Boolean(row.umodClans),
}))
}
module.exports = {
FRESH_MS,
boardsForAdmin,
REOFFER_MS,
CLAN_KINDS,
externalIdOf,
normaliseClan,
applyBoard,
applyEvent,
reofferActivity,
activityItem,
dedupeKeyOf,
canSeeRoster,
shapeBoard,
listForServer,
getForViewer,
}

View File

@@ -0,0 +1,202 @@
// ── module-rust's Team provider ────────────────────────────────────────────
//
// The questions core asks this module about Teams (MODULE_API.md
// `api.registerTeamProvider`, TEAMS.md §2.3). A first-party Rust clan is a Team
// (R5); this file is the whole of the translation, and `model/clans` is where
// the clans themselves are kept.
//
// ── The envelope is the contract ──────────────────────────────────────────
//
// Every method answers `{ ok, ... }` and `{ ok: false, reason }` is an ordinary
// answer. Core reads it as "keep what you have" — staleness, never emptiness —
// and there is no shape a failure can take that core reads as "zero Teams". An
// empty array is the one thing this file must never say while it does not know.
//
// ── Many servers, one answer (D53) ─────────────────────────────────────────
//
// `module-uo` has one shard and one socket, so "is the board current" has one
// answer. This module has a fleet, and the answer is per server. `getTeams` is
// therefore:
//
// • `complete: true` only when EVERY configured server's board is fresh,
// supported and untruncated — then core may archive a
// Team that is missing;
// • `complete: false` when at least one is current and some are not — core
// adds and updates, and removes nothing. One server being
// off for a patch must never archive its clans;
// • a refusal when none is current.
//
// A clan is only ever as current as its own server's board, so the roster
// methods ask about that server alone.
const core = require('../../core')
const db = require('./clans.db')
const clans = require('./clans.model')
const servers = require('../servers/servers.model')
const log = core.logger('teams')
const refuse = (reason) => ({ ok: false, reason })
/** Is this board record current? The rule `model/clans` states, applied to one row. */
function isFresh(board, now = Date.now()) {
return clans.shapeBoard(board, now).fresh
}
/**
* `getTeams()` — every clan on every server's board.
*
* `meta` carries the server and the clan's colour and score, opaquely: core
* stores and shows it and never branches on it.
*/
async function getTeams(now = Date.now()) {
try {
const configured = await servers.listForPolling()
if (!configured.length) return refuse('no Rust servers are configured')
const boards = await db.listBoards()
const byServer = new Map(boards.map((b) => [b.serverId, b]))
const fresh = []
const behind = []
for (const server of configured) {
const board = byServer.get(server.id)
if (isFresh(board, now)) fresh.push(server.id)
else behind.push(server.id)
}
if (!fresh.length) {
return refuse(`no server has sent a current clan board (${behind.join(', ')})`)
}
// Complete only when nothing is behind, and nothing is at the ceiling. A
// server that is configured but switched off in this module is "behind" by
// construction — its board is never read — which is the conservative answer:
// switching a server off is not a statement that its clans are gone.
const truncated = fresh.filter((id) => byServer.get(id).truncated)
const complete = behind.length === 0 && truncated.length === 0
const rows = await db.listActiveClans()
const known = new Set(configured.map((s) => s.id))
return {
ok: true,
complete,
teams: rows
.filter((row) => known.has(row.serverId))
.map((row) => ({
externalId: row.externalId,
name: row.name,
abbr: null,
meta: {
server: row.serverName || row.serverId,
serverId: row.serverId,
color: row.color || null,
score: Number(row.score) || 0,
},
})),
}
} catch (err) {
log.warn('getTeams failed', { error: err.message })
return refuse(`clans unreadable: ${err.message}`)
}
}
/** A clan and whether its server's board vouches for it right now, or a refusal. */
async function currentClan(externalId, now) {
const clan = await db.findClan(externalId)
if (!clan) return { refusal: refuse(`clan ${externalId} is not on any board`) }
if (clan.goneAt) return { refusal: refuse(`clan ${externalId} has left its server's board`) }
const board = await db.getBoard(clan.serverId)
if (!isFresh(board, now)) {
return { refusal: refuse(`server ${clan.serverId} has not sent a current clan board`) }
}
return { clan }
}
/**
* `getTeamMembers(externalId)` — one clan's roster.
*
* **A clan with no roster rows is refused, not reported empty**, unless the board
* said it has none. A clan always has at least its leader, so an empty roster
* beside a non-zero count is a read that happened between two writes, and
* reporting it would tell core every member left.
*/
async function getTeamMembers(externalId, now = Date.now()) {
try {
const { clan, refusal } = await currentClan(externalId, now)
if (refusal) return refusal
const rows = await db.listMembers(externalId)
if (!rows.length && Number(clan.memberCount) > 0) {
return refuse(`roster for clan ${externalId} is not stored yet (board says ${clan.memberCount} members)`)
}
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.steamId,
displayName: row.name || null,
rankLabel: row.role || null,
// Rank 1 is leader and several may hold it. A NULL rank — a role id the
// board could not match — is not a leader: "not known" must never read
// as "leads this clan".
leader: Number(row.rank) === 1,
online: Boolean(Number(row.online)),
userId: Number.isInteger(Number(row.userId)) && Number(row.userId) > 0 ? Number(row.userId) : null,
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, error: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/** `getTeamLeaders(externalId)` — everyone at rank 1, which may be several. */
async function getTeamLeaders(externalId, now = Date.now()) {
try {
const { refusal } = await currentClan(externalId, now)
if (refusal) return refusal
const rows = await db.listMembers(externalId)
return { ok: true, leaders: rows.filter((row) => Number(row.rank) === 1).map((row) => row.steamId) }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, error: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* Which roster rows a viewer may see (D48, MODULE_API 1.6.0).
*
* The one provider method core calls on a REQUEST path, and the one that fails
* CLOSED: core serves an empty roster when this refuses, because for a
* visibility question "keep what you have" would mean publishing the roster to
* whoever asked. So every path that cannot reach a confident answer refuses.
*
* All or nothing, and that is the model rather than a shortcut: the audience is
* a property of the ROSTER, not of a member. There is no setting in which some
* of a clan's members are visible and others are not.
*/
async function projectRoster(externalId, members, viewer) {
try {
const allowed = await clans.canSeeRoster(viewer, externalId)
if (!allowed) return { ok: true, members: [] }
return { ok: true, members: (members || []).map((m) => m.member_key).filter(Boolean) }
} catch (err) {
log.warn('projectRoster could not resolve the audience; withholding the roster', {
externalId, error: err.message,
})
return refuse(`the roster audience could not be resolved: ${err.message}`)
}
}
// Where core should point a link at a clan (MODULE_API 1.6.0, TEAMS.md §6.4).
// Core substitutes `{externalId}` and nothing else, which is why the page is not
// nested under its server (D56): the server is inside the id already.
const pageUrlTemplate = '/rust/clans/{externalId}'
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, pageUrlTemplate, isFresh }

View File

@@ -0,0 +1,79 @@
// ── The SQL half of the configuration audit ───────────────────────────────
//
// One table, two questions: record what a save did, and show an operator what
// has been done to a server lately.
//
// Nothing here talks to a game. The game half is `sidecarClient`, and the two
// are deliberately not mixed: this file is what remains true after the plugin
// has been reloaded, rolled back, or lost.
const core = require('../../core')
/**
* Records one save attempt — including the ones that never reached a file.
*
* A refusal is written for the same reason a success is: an operator asking why
* a setting is not what they set has to be able to see that somebody tried and
* was told no, and a table that only holds successes answers that question with
* silence.
*/
async function recordWrite(row) {
await core.query(
`INSERT INTO rust_config_writes
(server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded,
changes, version_before, version_after, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.serverId,
row.path,
row.plugin || null,
row.reloadTarget || null,
row.tier || 'form',
row.userId || null,
row.outcome,
row.reloaded ? 1 : 0,
row.changes ? JSON.stringify(row.changes) : null,
row.versionBefore || null,
row.versionAfter || null,
row.detail ? String(row.detail).slice(0, 500) : null,
],
)
}
/**
* The recent history for one server, newest first.
*
* `changes` comes back parsed, and a row whose JSON will not parse comes back
* with `null` rather than throwing — a corrupt audit row must not be able to
* break the page that displays the rest of them.
*/
async function recentWrites(serverId, limit = 50) {
const rows = await core.query(
`SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
user_id AS userId, outcome, reloaded, changes,
version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt
FROM rust_config_writes
WHERE server_id = ?
ORDER BY id DESC
LIMIT ?`,
[serverId, Math.max(1, Math.min(Number(limit) || 50, 200))],
)
return rows.map((row) => ({
...row,
reloaded: Boolean(row.reloaded),
changes: parseChanges(row.changes),
}))
}
function parseChanges(raw) {
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
module.exports = { recordWrite, recentWrites, parseChanges }

View File

@@ -0,0 +1,229 @@
// ── The logic half of configuration-from-the-site ─────────────────────────
//
// Everything here is about the difference between what a game host reports and
// what an admin should be shown. Three jobs:
//
// 1. **Group a flat file list by plugin**, because one plugin can own several
// files and a form that lists 40 paths is not a settings screen.
// 2. **Say which file is ours, and which keys inside it are locked** (D38). The
// plugin names itself in the catalogue rather than us matching a filename,
// so renaming the file cannot quietly unlock the three keys that would cut
// the link or split a server's history.
// 3. **Decide nothing about paths.** The only process that can say whether a
// path resolves inside a configuration directory is the one holding the
// directory. This file checks SHAPE, so an obviously malformed request is
// refused before it costs a round trip — never as a substitute for the real
// check on the host.
const configEdit = require('../../configEdit')
/**
* Keys in the bridge plugin's own config that the website may not change (D38).
*
* `Host` and `Port` are the link this edit is travelling over, and `ServerId` is
* how every row this module has ever stored is keyed — changing it does not
* rename a server, it strands its history and starts a new one under a name
* nobody chose deliberately. All three are editable on the host, by a person
* who is standing on it.
*/
const LOCKED_KEYS = ['Host', 'Port', 'ServerId']
/** What a locked field says for itself, on the screen and in a refusal. */
const LOCKED_REASON = {
host: 'the website reaches this server through this address',
port: 'the website reaches this server through this port',
serverid: 'every row this site holds for this server is keyed to this id',
}
/**
* A path shaped like something the host could plausibly have listed.
*
* Deliberately narrow and deliberately **not** the security boundary: no `..`,
* nothing absolute, no drive letter, forward slashes, and it ends in `.json`.
*/
const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/
function isPlausiblePath(path) {
return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path)
}
/**
* Shapes the plugin's catalogue into the screen's shape: plugins, each with its
* files, each file saying whether it can be edited and why not.
*
* A file whose guessed plugin is not loaded is kept and **marked**, not dropped.
* An operator whose config for an unloaded plugin vanished from the page would
* conclude the bridge cannot see it, which is a different and much more alarming
* problem than the true one.
*/
function shapeCatalogue(catalogue) {
if (!catalogue || typeof catalogue !== 'object') return null
const loaded = Array.isArray(catalogue.plugins) ? catalogue.plugins : []
const byName = new Map(loaded.map((p) => [String(p.name).toLowerCase(), p]))
const self = catalogue.self ? String(catalogue.self) : null
const groups = new Map()
for (const file of Array.isArray(catalogue.files) ? catalogue.files : []) {
const plugin = String(file.plugin || 'unknown')
const key = plugin.toLowerCase()
if (!groups.has(key)) {
const match = byName.get(key)
groups.set(key, {
plugin,
loaded: Boolean(match),
title: match ? match.title : null,
version: match ? match.version : null,
// The bridge plugin cannot reload itself — the reload would close the
// link carrying the answer — so the screen says so up front rather than
// offering a button that always refuses.
isBridge: self != null && plugin.toLowerCase() === self.toLowerCase(),
files: [],
})
}
groups.get(key).files.push({
path: String(file.path),
bytes: Number(file.bytes) || 0,
modified: file.modified ? Number(file.modified) : null,
editable: file.editable !== false,
...(file.reason ? { reason: String(file.reason) } : {}),
})
}
return {
root: catalogue.root ? String(catalogue.root) : null,
self,
truncated: Boolean(catalogue.truncated),
limits: catalogue.limits || null,
plugins: [...groups.values()].sort((a, b) => a.plugin.localeCompare(b.plugin)),
loaded: loaded
.map((p) => ({ name: String(p.name), title: p.title || null, version: p.version || null }))
.sort((a, b) => a.name.localeCompare(b.name)),
}
}
/** Whether this file is the bridge's own config, by the name the plugin gave. */
function isBridgeConfig(path, self) {
if (!self) return false
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
return plugin.toLowerCase() === String(self).toLowerCase()
}
/** The locked keys for a file: three of them in our own config, none anywhere else. */
function lockedKeysFor(path, self) {
return isBridgeConfig(path, self) ? LOCKED_KEYS : []
}
/**
* Turns one file the host sent into what the form renders.
*
* The text is passed through untouched. What is added is the READING of it: the
* field list, which fields are locked, and which hold something a browser should
* mask by default.
*/
function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
if (!file || typeof file.text !== 'string') return null
const locked = lockedKeysFor(file.path, self)
let fields = null
let parseError = null
try {
fields = configEdit.describe(configEdit.scan(file.text), { maxDepth, locked })
} catch (err) {
// A config already broken on disk still opens — in the raw tier, which is
// the only thing that can fix it. A page that refused to show a broken file
// would send somebody to SSH for the one job this feature exists to do.
parseError = err.message
}
return {
path: String(file.path),
plugin: file.plugin ? String(file.plugin) : null,
version: String(file.version),
bytes: Number(file.bytes) || 0,
modified: file.modified ? Number(file.modified) : null,
text: file.text,
fields,
parseError,
locked: locked.map((key) => ({ key, reason: LOCKED_REASON[key.toLowerCase()] || null })),
isBridge: isBridgeConfig(file.path, self),
}
}
/**
* Which locked keys differ between two versions of a document.
*
* The form refuses a locked field by pointer, but the **raw tier submits a whole
* document**, and a document can change `Port` without anything resembling an
* edit to a field. So the raw tier is checked the only way it can be: by
* comparing the literals before and after.
*
* A file that will not parse is not a way around this — an unparseable document
* is refused before it gets here.
*/
function lockedChanges(before, after, locked) {
if (!locked || locked.length === 0) return []
let a
let b
try {
a = configEdit.scan(before)
b = configEdit.scan(after)
} catch {
// Nothing can be compared, so nothing is cleared. The caller refuses.
return locked.slice()
}
const literal = (root, key) => {
if (root.type !== 'object') return null
const node = root.children.find((c) => String(c.key).toLowerCase() === key.toLowerCase())
return node ? JSON.stringify(node.value) + ':' + (node.raw || '') : null
}
return locked.filter((key) => literal(a, key) !== literal(b, key))
}
/** Every change a report says landed, as one line per file. */
function summariseReport(report) {
if (!report || typeof report !== 'object') return null
const files = Array.isArray(report.files) ? report.files : []
return {
ok: report.ok !== false,
reloaded: Boolean(report.reloaded),
rolledBack: Boolean(report.rolledBack),
reason: report.reason ? String(report.reason) : null,
// The plugin only reads this on the failure path, and it is the difference
// between "your change was undone" and "your change was undone BECAUSE line
// 14 is not valid for that field".
log: report.log ? String(report.log).slice(-4000) : null,
files: files.map((f) => ({
path: String(f.path),
version: f.version ? String(f.version) : null,
bytes: f.bytes != null ? Number(f.bytes) : null,
// Both frameworks merge missing defaults on load and save the file back,
// so the file after a successful reload is regularly not the file we
// wrote. Saying so keeps an operator from reading it as our bug.
rewritten: Boolean(f.rewritten),
})),
}
}
module.exports = {
LOCKED_KEYS,
LOCKED_REASON,
PATH_SHAPE,
isPlausiblePath,
shapeCatalogue,
shapeFile,
isBridgeConfig,
lockedKeysFor,
lockedChanges,
summariseReport,
}

View File

@@ -0,0 +1,289 @@
// ── SQL for the read path ─────────────────────────────────────────────────
//
// Writes come from one caller (`server/ingest.js`) and reads from the routers.
// They live together because they are the same tables and the invariants are
// easier to keep true when the UPDATE and the SELECT are on the same screen.
//
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always —
// except for one place where a list of kinds is expanded into placeholders, and
// that expansion is checked in `events.model.js` before it ever reaches here.
const core = require('../../core')
const EVENTS = 'rust_events'
const STATS = 'rust_player_wipe_stats'
const GATHER = 'rust_gather_totals'
const PLAYERS = 'rust_players'
const WIPES = 'rust_wipes'
const PRESENCE = 'rust_presence'
const CURSOR = 'rust_ingest_cursor'
// ── The cursor ────────────────────────────────────────────────────────────
async function getCursor(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, last_event_id AS lastEventId, events_seen AS eventsSeen
FROM ${CURSOR} WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/**
* Moves a server's cursor forward, counting what it passed.
*
* **Called only after the batch it describes has been written.** The whole
* correctness of the ingest is in that ordering: if this ran first, a crash
* between the two would skip events for ever, silently, with no way to notice.
* Running it last means a crash re-reads events it has already counted at worst
* — see `ingest.js` for what makes that survivable.
*/
async function setCursor(serverId, lastEventId, seen = 0) {
await core.query(
`INSERT INTO ${CURSOR} (server_id, last_event_id, events_seen, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
last_event_id = VALUES(last_event_id),
events_seen = events_seen + VALUES(events_seen),
updated_at = CURRENT_TIMESTAMP`,
[serverId, lastEventId, seen],
)
}
// ── Writes ────────────────────────────────────────────────────────────────
async function insertEvent({ serverId, wipeId, kind, t, steamId, raw }) {
await core.query(
`INSERT INTO ${EVENTS} (server_id, wipe_id, kind, t, steam_id, raw)
VALUES (?, ?, ?, ?, ?, ?)`,
[serverId, wipeId || null, kind, t, steamId || null, JSON.stringify(raw)],
)
}
/**
* Notes that a wipe exists, from any frame that mentions it.
*
* There is no "a wipe started" call, because the website is not there when one
* does — a wipe happens to a game server that was restarted while nobody was
* watching. A wipe is therefore created by being mentioned, and `last_seen`
* moves every time it is mentioned again.
*/
async function touchWipe(serverId, wipeId, saveCreatedAt = null) {
if (!wipeId) return
await core.query(
`INSERT INTO ${WIPES} (server_id, wipe_id, save_created_at, first_seen, last_seen)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
last_seen = CURRENT_TIMESTAMP,
save_created_at = COALESCE(VALUES(save_created_at), save_created_at)`,
[serverId, wipeId, saveCreatedAt],
)
}
/**
* Notes that a player exists and what they were last called.
*
* `name` is COALESCEd rather than overwritten so that a frame which carries no
* name — a ban by id, a tally — cannot blank out the name every other frame
* supplied.
*/
async function touchPlayer(steamId, name = null) {
if (!steamId) return
await core.query(
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
name = COALESCE(VALUES(name), name),
last_seen = CURRENT_TIMESTAMP`,
[steamId, name],
)
}
/**
* Adds to one player's counters for one wipe.
*
* Every column is a running total that only rises within a wipe, so this is an
* upsert that ADDS rather than sets. `deltas` names only what moved; a `+ 0` on
* everything else is what keeps the caller from having to read the row first.
*/
async function addStats({ serverId, wipeId, steamId }, deltas = {}) {
if (!serverId || !steamId) return
const cols = ['kills', 'deaths', 'suicides', 'npc_kills', 'structures', 'sessions', 'playtime_sec']
const values = {
kills: deltas.kills || 0,
deaths: deltas.deaths || 0,
suicides: deltas.suicides || 0,
npc_kills: deltas.npcKills || 0,
structures: deltas.structures || 0,
sessions: deltas.sessions || 0,
playtime_sec: deltas.playtimeSec || 0,
}
await core.query(
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, ${cols.join(', ')}, last_seen)
VALUES (?, ?, ?, ${cols.map(() => '?').join(', ')}, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
${cols.map((c) => `${c} = ${c} + VALUES(${c})`).join(',\n ')},
last_seen = CURRENT_TIMESTAMP`,
[serverId, wipeId || '', steamId, ...cols.map((c) => values[c])],
)
}
async function addGathered({ serverId, wipeId, steamId }, resource, amount) {
if (!serverId || !steamId || !resource || !(amount > 0)) return
await core.query(
`INSERT INTO ${GATHER} (server_id, wipe_id, steam_id, resource, amount)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE amount = amount + VALUES(amount)`,
[serverId, wipeId || '', steamId, resource, amount],
)
}
/**
* Replaces a server's presence rows with exactly what the board said.
*
* Two statements, delete then insert, because a board is a REPLACEMENT: a player
* who left between two boards has to disappear, and an upsert alone would leave
* them online for ever. It is not wrapped in a transaction on purpose — the
* window between the two is a fraction of a second of a page possibly showing an
* empty player list, against holding a lock on a table two routes read.
*/
async function replacePresence(serverId, players = []) {
await core.query(`DELETE FROM ${PRESENCE} WHERE server_id = ?`, [serverId])
for (const p of players) {
if (!p || !p.steamId) continue
await core.query(
`INSERT INTO ${PRESENCE} (server_id, steam_id, name, sleeping, connected_at, updated_at)
VALUES (?, ?, ?, ?, ${p.connectedAt ? 'FROM_UNIXTIME(? / 1000)' : 'NULL'}, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
name = VALUES(name), sleeping = VALUES(sleeping), updated_at = CURRENT_TIMESTAMP`,
p.connectedAt
? [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0, p.connectedAt]
: [serverId, p.steamId, p.name || null, p.sleeping ? 1 : 0],
)
}
}
/** Deletes raw events older than `days`. Totals are never touched — that is the point of them. */
async function pruneEvents(days) {
if (!(days > 0)) return 0
const res = await core.query(
`DELETE FROM ${EVENTS} WHERE created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? DAY)`,
[days],
)
return (res && res.affectedRows) || 0
}
// ── Reads ─────────────────────────────────────────────────────────────────
/**
* Recent events, newest first, restricted to `kinds`.
*
* **`kinds` is never optional.** A default of "all kinds" is one forgotten
* argument away from publishing an IP address, so the caller is made to say it
* every time; `events.model.js` builds the list from the catalogue's allowlist
* and an empty list answers with no rows rather than with everything.
*/
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
if (!Array.isArray(kinds) || kinds.length === 0) return []
const holes = kinds.map(() => '?').join(', ')
const params = [serverId, ...kinds]
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
FROM ${EVENTS}
WHERE server_id = ? AND kind IN (${holes})`
if (wipeId) {
sql += ' AND wipe_id = ?'
params.push(wipeId)
}
sql += ' ORDER BY id DESC LIMIT ?'
params.push(limit)
return core.query(sql, params)
}
/**
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
*
* All-time is a SUM over the per-wipe rows rather than a separate set of
* counters, which is what makes it impossible for the two to disagree — there
* is only ever one number, added up differently.
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
const params = [serverId]
let where = 's.server_id = ?'
if (wipeId) {
where += ' AND s.wipe_id = ?'
params.push(wipeId)
}
params.push(limit)
return core.query(
`SELECT s.steam_id AS steamId,
p.name AS name,
SUM(s.kills) AS kills,
SUM(s.deaths) AS deaths,
SUM(s.npc_kills) AS npcKills,
SUM(s.structures) AS structures,
SUM(s.playtime_sec) AS playtimeSec,
MAX(s.last_seen) AS lastSeen
FROM ${STATS} s
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
WHERE ${where}
GROUP BY s.steam_id, p.name
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
LIMIT ?`,
params,
)
}
async function listWipes(serverId) {
return core.query(
`SELECT wipe_id AS wipeId, save_created_at AS saveCreatedAt,
first_seen AS firstSeen, last_seen AS lastSeen
FROM ${WIPES}
WHERE server_id = ?
ORDER BY wipe_id DESC`,
[serverId],
)
}
async function presenceFor(serverId) {
return core.query(
`SELECT steam_id AS steamId, name, sleeping, connected_at AS connectedAt
FROM ${PRESENCE}
WHERE server_id = ?
ORDER BY name ASC`,
[serverId],
)
}
module.exports = {
getCursor,
setCursor,
insertEvent,
touchWipe,
touchPlayer,
addStats,
addGathered,
replacePresence,
pruneEvents,
recentEvents,
leaderboard,
listWipes,
presenceFor,
}

View File

@@ -0,0 +1,166 @@
// ── The read path's logic ─────────────────────────────────────────────────
//
// Everything that decides WHAT a caller gets, separated from the SQL that
// fetches it, so this file can be tested with no database and `events.db.js` has
// no branching to test.
//
// The decision that matters here is not a business rule, it is a boundary: what
// a signed-out visitor may see. Protocol 2 carries IP addresses and player
// reports, and the only thing standing between them and a public page is
// `catalogue.js`'s allowlist and the fact that **every read on this file takes an
// explicit viewer**. There is no default, because a default is what a caller
// gets when they forget — and the safe value is never the one that is easier to
// type.
const catalogue = require('../../catalogue')
const db = require('./events.db')
/** Hard ceiling on a page, whatever a caller asks for. */
const MAX_LIMIT = 200
function boundedLimit(requested, fallback = 50) {
const n = Number(requested)
if (!Number.isFinite(n) || n <= 0) return fallback
return Math.min(Math.trunc(n), MAX_LIMIT)
}
/**
* Parses a `kind` query parameter into a list.
*
* Accepts `?kind=player.death` and `?kind=player.death,player.chat`, and answers
* `null` for anything empty — which means "whatever this viewer may see" rather
* than "nothing", and is then narrowed by the catalogue.
*/
function parseKinds(raw) {
if (!raw) return null
const list = String(raw)
.split(',')
.map((k) => k.trim())
.filter(Boolean)
return list.length > 0 ? list : null
}
/**
* Recent events for one server, already narrowed to what this viewer may see.
*
* **`admin` is a parameter, not a default.** A route that forgets it gets the
* public list, which is the direction it is safe to be wrong in. And a kind the
* caller asked for that they may not see is dropped silently rather than
* refused: naming it in an error would confirm the kind exists, which is a small
* thing to leak and a free one to avoid.
*/
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
// Every requested kind was refused. Answering with an empty list is right —
// the events they asked for are, as far as they are concerned, not there.
if (kinds.length === 0) return []
const rows = await db.recentEvents({
serverId,
kinds,
wipeId,
limit: boundedLimit(limit),
})
return rows.map(shape)
}
/**
* One stored row as an API object.
*
* `raw` comes back from the database as text and is parsed here rather than in
* the db layer, because a row whose JSON will not parse is a reporting problem
* and not a query problem: it answers with the envelope it does know and an
* empty body, instead of failing a whole page over one bad row.
*/
function shape(row) {
let frame = {}
try {
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw || {}
} catch {
frame = {}
}
return {
id: Number(row.id),
kind: row.kind,
t: Number(row.t),
wipeId: row.wipeId || null,
steamId: row.steamId || null,
frame,
}
}
/**
* The leaderboard for a server, per wipe or all-time.
*
* All-time is the same rows summed differently rather than a second set of
* counters, so the two can never disagree — which is the whole reason R12's
* "per-wipe detail plus all-time rollups" is one table and not two.
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
const rows = await db.leaderboard({
serverId,
wipeId,
sort,
limit: boundedLimit(limit, 25),
})
return rows.map((r) => ({
steamId: r.steamId,
name: r.name || null,
kills: Number(r.kills) || 0,
deaths: Number(r.deaths) || 0,
npcKills: Number(r.npcKills) || 0,
structures: Number(r.structures) || 0,
playtimeSec: Number(r.playtimeSec) || 0,
// Withheld below the presence audience. A tally refreshes it every minute a
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
// another name. The ORDER still uses it as a tie-break — that says who was
// on more recently, never whether anybody is on now.
...(presence ? { lastSeen: r.lastSeen || null } : {}),
}))
}
/**
* Every wipe this server has had, newest first.
*
* The list is what makes the per-wipe view navigable, and it is also the proof
* R12 asks for: a wipe that ended is still here, with its stats still attached.
*/
async function wipes(serverId) {
const rows = await db.listWipes(serverId)
return rows.map((r) => ({
wipeId: r.wipeId,
saveCreatedAt: r.saveCreatedAt || null,
firstSeen: r.firstSeen,
lastSeen: r.lastSeen,
}))
}
/**
* Who is on the server right now.
*
* Read from the presence board rather than counted from connect and disconnect
* events: the board is re-sent on every bridge connect and every minute, so it
* is right even after this module has missed something. Counting transitions
* instead would drift, and drift in exactly the direction people notice —
* players who never left.
*/
async function online(serverId) {
const rows = await db.presenceFor(serverId)
return rows.map((r) => ({
steamId: r.steamId,
name: r.name || null,
sleeping: Boolean(r.sleeping),
connectedAt: r.connectedAt || null,
}))
}
module.exports = { recent, leaderboard, wipes, online, parseKinds, boundedLimit, MAX_LIMIT }

View File

@@ -0,0 +1,159 @@
// ── SQL, and nothing else ─────────────────────────────────────────────────
//
// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its
// keep). Raw parameterised SQL through `core.query`, placeholders always.
const core = require('../../core')
const LINKS = 'rust_account_links'
const PLAYERS = 'rust_players'
const STATS = 'rust_player_wipe_stats'
/**
* The link for one Steam id, or undefined.
*
* Joins core's `users` for the username, because every caller that asks "who
* owns this?" wants a name rather than an integer — and the one caller that
* refuses a re-link has to be able to say *whose* it is.
*/
async function getBySteamId(steamId) {
const rows = await core.query(
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
l.linked_at AS linkedAt, u.username
FROM ${LINKS} l
JOIN users u ON u.id = l.user_id
WHERE l.steam_id = ?`,
[steamId],
)
return rows[0]
}
/**
* Every Steam account one website user holds, newest first.
*
* **It joins `rust_players` for the name the game last saw**, and that is not a
* convenience. The name on the LINK is what the player was called at the moment
* they linked, which is a Rust name and changes on a whim — so a player who has
* renamed since sees a name they no longer use, on the one page of the site that
* is about who they are. The admin panel already preferred the newer one; this
* is the same rule applied where the person themselves is reading.
*
* A LEFT JOIN, because a player can link an account and never play on it.
*/
async function listForUser(userId) {
return core.query(
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
l.linked_at AS linkedAt, p.name AS playerName
FROM ${LINKS} l
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
WHERE l.user_id = ?
ORDER BY l.linked_at DESC`,
[userId],
)
}
/**
* Record a link.
*
* **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in
* SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one
* website account to another — which, once phase 7 makes a link a privilege path
* and phase 13 makes it an entitlement, is an account takeover performed by
* typing a six-character code. The duplicate-key error is the refusal, and the
* controller turns it into a sentence.
*/
async function insert({ steamId, userId, name, serverId }) {
await core.query(
`INSERT INTO ${LINKS} (steam_id, user_id, name, server_id)
VALUES (?, ?, ?, ?)`,
[steamId, userId, name || null, serverId || null],
)
}
/**
* Remove a link the caller owns.
*
* Scoped by `user_id` in the statement rather than checked before it: a delete
* that reads, decides, then writes has a gap between the read and the write, and
* this way the ownership test and the deletion are the same operation. Answers
* how many rows went, so a caller can tell "removed" from "was not yours".
*/
async function removeOwned(steamId, userId) {
const result = await core.query(
`DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`,
[steamId, userId],
)
return Number(result && result.affectedRows) || 0
}
/**
* Remove a link whoever holds it — the in-game `/unlink` path, and the staff
* unlink on the `admin.users.detail` panel (D25).
*
* Unscoped by user on purpose: neither caller is the link's owner and both have
* already established their authority another way. In game the authority is the
* Steam account itself — whoever is connected as it is who it is; on the admin
* panel it is the tier gate. Which is why the admin caller writes an
* `activity.log` entry naming the operator and this does not: it cannot tell the
* two apart, and a log line that guessed would be worse than none.
*/
async function removeBySteamId(steamId) {
const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId])
return Number(result && result.affectedRows) || 0
}
/**
* Every link one user holds, enriched with what this module knows about that
* player — for the `admin.users.detail` panel.
*
* A LEFT JOIN, because a player can link an account and never play on it. An
* operator looking at that user should see the link, not an empty panel.
*/
async function listForUserWithPlayer(userId) {
return core.query(
`SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt,
p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen
FROM ${LINKS} l
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
WHERE l.user_id = ?
ORDER BY l.linked_at DESC`,
[userId],
)
}
/**
* Per-server all-time totals for one Steam id.
*
* The same rows the public leaderboard sums, grouped by server instead of
* filtered to one — so an operator sees a player across the fleet in one read.
* All-time, deliberately: an admin looking at a user wants their history, not
* this week's.
*/
async function statsForSteamId(steamId) {
return core.query(
`SELECT s.server_id AS serverId, srv.name AS serverName,
SUM(s.kills) AS kills,
SUM(s.deaths) AS deaths,
SUM(s.npc_kills) AS npcKills,
SUM(s.structures) AS structures,
SUM(s.playtime_sec) AS playtimeSec,
MAX(s.last_seen) AS lastSeen,
COUNT(DISTINCT s.wipe_id) AS wipes
FROM ${STATS} s
LEFT JOIN rust_servers srv ON srv.id = s.server_id
WHERE s.steam_id = ?
GROUP BY s.server_id, srv.name
ORDER BY SUM(s.playtime_sec) DESC`,
[steamId],
)
}
module.exports = {
getBySteamId,
listForUser,
listForUserWithPlayer,
insert,
removeOwned,
removeBySteamId,
statsForSteamId,
}

View File

@@ -0,0 +1,281 @@
// ── Who owns which Steam account ──────────────────────────────────────────
//
// R1's identity link, site-side. The flow it sits in the middle of:
//
// 1. In game, a player types `/link`. The plugin mints a one-time code, tells
// them privately, and holds it in memory for five minutes.
// 2. On the website, the player types that code. This module asks the sidecar,
// which asks the plugin, which answers with the Steam id the code belongs
// to and drops it.
// 3. This file records the result.
//
// **The site is the author of record and the game holds nothing.** That is the
// one real difference from the UO bridge, which writes a tag onto the game
// account: there is no equivalent per-account store in Rust that survives a wipe,
// and phase 7 needs the site to be 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.
const core = require('../../core')
const db = require('./links.db')
const servers = require('../servers/servers.model')
const sidecar = require('../../sidecarClient')
const log = core.logger('links')
/**
* A link changed, so a clan member's website account changed (D57).
*
* Core resolves a Team member's `userId` from the provider's answer, and that
* answer comes from this table. Without asking, a member who links today is not
* a member of their clan's Team on the site until core's next scheduled sweep —
* fifteen minutes by default — which is exactly when a player tries the clan
* forum for the first time. A request, not a wait: it returns at once and never
* throws into the link flow.
*/
function linksChanged(reason) {
try {
core.teams.reconcile({ reason })
} catch (err) {
log.warn('could not ask core to reconcile Teams after a link change', { reason, error: err.message })
}
}
/** What a link looks like to any caller. Never carries a raw code. */
function shape(row) {
if (!row) return null
return {
steamId: row.steamId,
name: row.name || null,
serverId: row.serverId || null,
linkedAt: row.linkedAt,
}
}
/**
* The Steam accounts one website user holds.
*
* The name is the one the GAME last saw, falling back to the one recorded when
* they linked — the rule the admin panel already used, applied on the page the
* player themselves reads. A browser walk found the two disagreeing: staff saw
* `Wanderer` and the player saw `Wanderer-old`, for the same person on the same
* site.
*/
async function listForUser(userId) {
return (await db.listForUser(userId)).map((row) => ({
...shape(row),
name: row.playerName || row.name || null,
}))
}
/** True when this user holds this Steam id. The ownership gate every player read uses. */
async function owns(steamId, userId) {
const row = await db.getBySteamId(steamId)
return Boolean(row && Number(row.userId) === Number(userId))
}
/**
* Redeem a code against one server, and record the link.
*
* Answers a discriminated result rather than throwing, because every outcome
* here is a sentence somebody has to read:
*
* `{ ok: true, link }` — linked
* `{ ok: false, reason: 'rejected' }`— the game says that code is not good
* `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id
* `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer
*
* **`rejected` deliberately collapses "unknown" and "expired".** The plugin
* distinguishes them and an operator reading its log can too; a stranger typing
* codes must not learn which of the two they hit, because that is the difference
* between "keep guessing" and "guess faster".
*/
async function confirmOne({ server, code, userId }) {
const result = await sidecar.confirmLink(server, code)
// The transport failed: the sidecar is unreachable, the game is not connected,
// or the reply never came. None of those is a verdict on the code, so the
// player is told to try again rather than that their code is wrong.
if (!result.ok) {
log.warn('link confirm did not reach the game', { server: server.id, status: result.status })
return { ok: false, reason: 'offline' }
}
const frame = result.data || {}
// The plugin's own refusal. `frame.reason` is `unknown`, `expired` or
// `malformed`; it is logged and not surfaced (see the doc above).
if (frame.kind !== 'link.ok' || !frame.steamId) {
log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' })
return { ok: false, reason: 'rejected' }
}
const steamId = String(frame.steamId)
const held = await db.getBySteamId(steamId)
// D23: refuse, and say whose it is. A move would transfer every permission and
// entitlement phases 7 and 13 hang off this link, on a code anybody in game
// could have run — and the player's way out is `/unlink` in game, which they
// can reach from the machine they are sitting at.
if (held) {
if (Number(held.userId) === Number(userId)) {
// Already theirs. Not an error: a player who pressed the button twice, or
// one whose code was confirmed on a request that then timed out.
return { ok: true, link: shape(held), already: true }
}
return { ok: false, reason: 'taken', username: held.username }
}
try {
await db.insert({
steamId,
userId,
name: frame.name || null,
serverId: server.id,
})
} catch (err) {
// The race the PRIMARY KEY exists for: two confirmations of the same Steam
// id, interleaved between the check above and this write. The key refuses the
// second and it becomes the same refusal, rather than a 500.
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
const now = await db.getBySteamId(steamId)
if (now && Number(now.userId) === Number(userId)) {
return { ok: true, link: shape(now), already: true }
}
return { ok: false, reason: 'taken', username: now && now.username }
}
throw err
}
const link = shape(await db.getBySteamId(steamId))
log.info('steam account linked', { steamId, userId, server: server.id })
linksChanged('rust account linked')
return { ok: true, link }
}
/**
* Redeem a code against the fleet (D24).
*
* **A code is minted by ONE server and the player types six characters into a
* browser**, so the site cannot know which server it came from — nothing in the
* code says, and asking the player to pick would make a wrong guess
* indistinguishable from a wrong code, which is the one refusal that must not be
* ambiguous. So every enabled server is asked in turn and the first `link.ok`
* wins. The others answer `unknown` and nothing happens there: a code is only
* spent at the server that actually holds it.
*
* The loop stops early on `taken`, because that is a verdict about the Steam id
* rather than about this server — asking the rest of the fleet would produce the
* same answer more slowly.
*
* **"Every reachable server refused" is not the same answer as "a server was
* unreachable"**, and collapsing them is how a player who linked on the one
* server that is down gets told their code is wrong. `unsure` is that case, and
* the sentence it earns says to try again rather than to run `/link` again.
*/
async function redeem({ code, userId }) {
const fleet = await servers.listForPolling()
if (fleet.length === 0) return { ok: false, reason: 'no-servers' }
let refused = 0
let unreachable = 0
for (const server of fleet) {
// Sequential, deliberately. In parallel every server would be asked even
// after one had already answered, and a code spent on the right server would
// still be travelling to five others — for a fleet of six and a five-minute
// TTL, there is nothing to win by racing them.
// eslint-disable-next-line no-await-in-loop
const result = await confirmOne({ server, code, userId })
if (result.ok || result.reason === 'taken') return result
if (result.reason === 'offline') unreachable += 1
else refused += 1
}
if (refused === 0) return { ok: false, reason: 'offline' }
if (unreachable > 0) return { ok: false, reason: 'unsure' }
return { ok: false, reason: 'rejected' }
}
/** Remove a link the caller owns. False when they did not hold it. */
async function unlinkOwned(steamId, userId) {
const removed = (await db.removeOwned(steamId, userId)) > 0
if (removed) linksChanged('rust account unlinked')
return removed
}
/**
* Remove a link whoever holds it.
*
* Two callers, both of which have already established their authority and
* neither of which is the link's owner: ingest applying an in-game `/unlink`
* (the authority is the Steam account — whoever is connected as it is who it
* is), and a staff unlink from the `admin.users.detail` panel (D25).
*
* It logs nothing about who asked, because the two callers record that
* differently: the admin one writes an `activity.log` entry naming the operator,
* and the game one has no operator to name.
*/
async function unlinkAnyOwner(steamId) {
const removed = (await db.removeBySteamId(steamId)) > 0
if (removed) linksChanged('rust account unlinked')
return removed
}
/**
* Remove a link because the player asked in game.
*
* Called from ingest, off an `account.unlinked` event.
*/
async function unlinkFromGame(steamId) {
const removed = await unlinkAnyOwner(steamId)
if (removed) log.info('steam account unlinked in game', { steamId })
return removed
}
/** The admin panel's read: every link this user holds, with per-server totals. */
async function forAdmin(userId) {
const links = await db.listForUserWithPlayer(userId)
return Promise.all(
links.map(async (row) => ({
steamId: row.steamId,
// The name on the LINK is what they were called when they linked; the one
// on `rust_players` is what the game last saw. They differ the moment
// somebody renames, and the newer one is the useful one to show.
name: row.playerName || row.name || null,
linkedName: row.name || null,
serverId: row.serverId || null,
linkedAt: row.linkedAt,
firstSeen: row.firstSeen || null,
lastSeen: row.lastSeen || null,
servers: (await db.statsForSteamId(row.steamId)).map((s) => ({
serverId: s.serverId,
serverName: s.serverName || s.serverId,
kills: Number(s.kills) || 0,
deaths: Number(s.deaths) || 0,
npcKills: Number(s.npcKills) || 0,
structures: Number(s.structures) || 0,
playtimeSec: Number(s.playtimeSec) || 0,
wipes: Number(s.wipes) || 0,
lastSeen: s.lastSeen || null,
})),
})),
)
}
module.exports = {
shape,
listForUser,
owns,
confirmOne,
redeem,
unlinkOwned,
unlinkAnyOwner,
unlinkFromGame,
forAdmin,
}

View File

@@ -0,0 +1,501 @@
// ── SQL for the permission mirror, and nothing else ───────────────────────
//
// The tables this file reads are described at length in `db/schema.sql`; what
// matters here is which of them is authoritative for what, because four of the
// eight look similar and answer completely different questions:
//
// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`,
// `rust_perm_grants` — what an operator (and later an event) says
// should be true. Keyed by WEBSITE USER (D28).
// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's
// store. Keyed by STEAM ID, because it records what is in the game
// and the game has never heard of a website account.
// FOUND `rust_perm_drift` — what a sync found that the site did not
// author. Replaced whole by each report: it is the current
// difference, not a history of differences.
// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put
// it there. The only way to act on drift, since a foreign grant
// often names a Steam id no website account holds.
//
// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js`
// here. Bulk writes are batched into one statement with a generated placeholder
// list rather than looped, because a fleet-wide sync writes hundreds of rows and
// a round trip each is how a boot tick becomes a second long.
const core = require('../../core')
const GROUPS = 'rust_perm_groups'
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
const GROUP_MEMBERS = 'rust_perm_group_members'
const GRANTS = 'rust_perm_grants'
const PUSHED = 'rust_perm_pushed'
const DRIFT = 'rust_perm_drift'
const REVOCATIONS = 'rust_perm_revocations'
const SYNC = 'rust_perm_sync'
const CATALOGUE = 'rust_perm_catalogue'
const LINKS = 'rust_account_links'
const SERVERS = 'rust_servers'
/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
function placeholders(rows, width) {
return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
}
// ---- the authored set ----
async function listGroups() {
return core.query(
`SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt
FROM ${GROUPS}
ORDER BY \`rank\` DESC, name ASC`,
)
}
async function getGroup(name) {
const rows = await core.query(
`SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`,
[name],
)
return rows[0] || null
}
/**
* Create or update one group.
*
* `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the
* same screen is not a race worth losing a title over, and the row's identity is
* its name either way.
*/
async function upsertGroup({ name, title, rank, scope }) {
await core.query(
`INSERT INTO ${GROUPS} (name, title, \`rank\`, scope)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`),
scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`,
[name, title, rank, scope],
)
}
async function deleteGroup(name) {
const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name])
return Number(result.affectedRows || 0) > 0
}
async function listGroupPermissions() {
return core.query(
`SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`,
)
}
/** Replace a group's permission list whole. The form edits a list, so the write is a list. */
async function setGroupPermissions(name, permissions) {
await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name])
if (!permissions.length) return
await core.query(
`INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission)
VALUES ${placeholders(permissions, 2)}`,
permissions.flatMap((permission) => [name, permission]),
)
}
/**
* Every membership, with the member's Steam accounts joined on.
*
* One query rather than a membership read plus a link read per member: the admin
* screen renders both together and the push needs both together, and a fleet's
* worth of members is one round trip either way.
*/
async function listGroupMembers() {
return core.query(
`SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt,
u.username, l.steam_id AS steamId, p.name AS playerName
FROM ${GROUP_MEMBERS} m
JOIN users u ON u.id = m.user_id
LEFT JOIN ${LINKS} l ON l.user_id = m.user_id
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
ORDER BY m.group_name ASC, u.username ASC`,
)
}
async function addGroupMember(groupName, userId, addedBy) {
await core.query(
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`,
[groupName, userId, addedBy],
)
}
async function removeGroupMember(groupName, userId) {
const result = await core.query(
`DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`,
[groupName, userId],
)
return Number(result.affectedRows || 0) > 0
}
/**
* Every direct grant, with the holder's accounts joined on.
*
* `username` is on the row because a grant with no linked Steam account still
* has to be listable and nameable — that state is the one the admin screen most
* needs to show, since it looks exactly like a working grant from every other
* angle and reaches nobody.
*/
async function listGrants({ userId = null } = {}) {
return core.query(
`SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
g.granted_at AS grantedAt, u.username,
l.steam_id AS steamId, p.name AS playerName
FROM ${GRANTS} g
JOIN users u ON u.id = g.user_id
LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
${userId === null ? '' : 'WHERE g.user_id = ?'}
ORDER BY u.username ASC, g.permission ASC`,
userId === null ? [] : [userId],
)
}
async function getGrant(id) {
const rows = await core.query(
`SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`,
[id],
)
return rows[0] || null
}
/**
* Add a grant, or leave the one that is already there alone.
*
* `INSERT IGNORE` against the unique key, and the return says which happened —
* the controller needs to tell "granted" from "they already had it" to write an
* honest activity row.
*/
async function insertGrant({ userId, permission, scope, source, note, grantedBy }) {
const result = await core.query(
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[userId, permission, scope, source, note, grantedBy],
)
return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId }
}
async function deleteGrant(id) {
const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])
return Number(result.affectedRows || 0) > 0
}
/**
* One website account by name, for the authoring form.
*
* A form that made an operator type a numeric user id would be a form nobody
* could use, and the alternative — calling core's own admin user search from the
* client — would bind this module to the shape of a response the contract does
* not cover. Reading the `users` table is already what every join in this file
* does.
*
* Case-insensitive because the column's collation is: core stores usernames in a
* `_ci` collation and an exact-case lookup would refuse a name the site itself
* considers the same one.
*/
async function findUserByUsername(username) {
const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username])
return rows[0] || null
}
/** Which website user holds which Steam account. The join that turns an authored row into a push. */
async function listLinks() {
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
}
// ---- one person's own half of all of it (the player tier) ----
//
// Every read below is scoped inside the statement rather than filtered after it.
// The admin reads above answer "who holds what"; these answer "what do I hold",
// and the difference between the two is a `WHERE` that must not be somebody
// else's job to remember.
/** The groups one website user belongs to. Ordered the way the admin list is. */
async function listGroupsForUser(userId) {
return core.query(
`SELECT g.name, g.title, g.\`rank\`, g.scope, m.added_at AS addedAt
FROM ${GROUP_MEMBERS} m
JOIN ${GROUPS} g ON g.name = m.group_name
WHERE m.user_id = ?
ORDER BY g.\`rank\` DESC, g.name ASC`,
[userId],
)
}
/**
* Every pushed row naming one of these Steam ids, across every server.
*
* The pushed ledger is keyed by Steam id because it records what is in a GAME
* (D28's other half), so this is the one read in the file that starts from an
* account rather than from a user. `kind` is carried through: a direct grant and
* a group membership are different rows about the same person and only the
* caller can say which of them it was looking for.
*/
async function listPushedForSteamIds(steamIds) {
if (!steamIds.length) return []
return core.query(
`SELECT server_id AS serverId, kind, subject, object
FROM ${PUSHED}
WHERE subject IN (${steamIds.map(() => '?').join(',')})
AND kind IN ('grant', 'member')`,
steamIds,
)
}
// ---- what is actually out there ----
async function listPushed(serverId) {
return core.query(
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
[serverId],
)
}
async function addPushed(serverId, rows) {
if (!rows.length) return
await core.query(
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
)
}
async function removePushed(serverId, rows) {
for (const row of rows) {
// eslint-disable-next-line no-await-in-loop
await core.query(
`DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
[serverId, row.kind, row.subject, row.object],
)
}
}
/**
* Replace one server's drift list with what the latest report found.
*
* Whole, rather than merged, and `first_seen` survives through the
* `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still
* answerable while "somebody has since undone it" removes the row.
*/
async function replaceDrift(serverId, rows) {
if (!rows.length) {
await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId])
return
}
await core.query(
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
)
// Anything this report did NOT name is gone from the game, so it goes from
// here. Named explicitly rather than swept by timestamp: two syncs a second
// apart would make a timestamp window either delete live rows or keep dead
// ones, depending on the clock.
await core.query(
`DELETE FROM ${DRIFT}
WHERE server_id = ?
AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
[serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
)
}
async function listDrift() {
return core.query(
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
l.user_id AS userId, u.username, p.name AS playerName
FROM ${DRIFT} d
LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN rust_players p ON p.steam_id = d.subject
ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
)
}
async function getDrift(id) {
const rows = await core.query(
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
[id],
)
return rows[0] || null
}
async function deleteDrift(id) {
await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
}
async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
await core.query(
`INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
VALUES (?, ?, ?, ?, ?)`,
[serverId, kind, subject, object, requestedBy],
)
}
async function listRevocations(serverId) {
return core.query(
`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`,
[serverId],
)
}
async function deleteRevocations(ids) {
if (!ids.length) return
await core.query(
`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
// ---- the state of the mirror ----
/**
* One sync row per configured server, created on demand.
*
* A server added today has no row and must not therefore be skipped for ever, so
* the read inserts what is missing rather than the writer remembering to.
*/
async function ensureSyncRows() {
await core.query(
`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`,
)
}
async function listSync() {
return core.query(
`SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash,
s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId,
s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt,
s.report, s.error
FROM ${SYNC} s
ORDER BY s.server_id ASC`,
)
}
/**
* Mark servers as needing a sync.
*
* `scope` is a server id or `*`; a fleet-wide change dirties every row, which is
* right: the set each server should hold has changed even if only one of them
* will notice a difference.
*/
async function markDirty(scope) {
if (!scope || scope === '*') {
await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
return
}
await core.query(
`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`,
[scope],
)
}
/**
* Record the outcome of one attempt.
*
* **`dirty` is cleared unconditionally, and that is safe because it is an
* optimisation rather than the truth.** Something may well have changed the
* authored set while this sync was in flight, and clearing the flag would then
* lose that change — except that the loop's real condition is
* `desired_hash != synced_hash`, recomputed from the tables on every tick. The
* flag only saves a hash comparison; the hash is what cannot be wrong.
*
* `last_ok_at` moves only on success, and it is passed rather than composed into
* the SQL so the statement is the same string every time.
*/
async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
const okAt = state === 'ok' ? new Date() : null
await core.query(
`INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
last_attempt_at, last_ok_at, report, error, updated_at)
VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
desired_hash = VALUES(desired_hash),
synced_hash = VALUES(synced_hash),
boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
last_attempt_at = NOW(),
last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
report = VALUES(report), error = VALUES(error),
updated_at = NOW()`,
[serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
)
}
// ---- the option source ----
async function putCatalogue(serverId, permissions) {
await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
if (!permissions.length) return
await core.query(
`INSERT IGNORE INTO ${CATALOGUE} (server_id, permission)
VALUES ${placeholders(permissions, 2)}`,
permissions.flatMap((permission) => [serverId, permission]),
)
}
async function listCatalogue() {
return core.query(
`SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`,
)
}
module.exports = {
GROUPS,
GRANTS,
PUSHED,
DRIFT,
listGroups,
getGroup,
upsertGroup,
deleteGroup,
listGroupPermissions,
setGroupPermissions,
listGroupMembers,
addGroupMember,
removeGroupMember,
listGrants,
getGrant,
insertGrant,
deleteGrant,
findUserByUsername,
listLinks,
listGroupsForUser,
listPushedForSteamIds,
listPushed,
addPushed,
removePushed,
replaceDrift,
listDrift,
getDrift,
deleteDrift,
queueRevocation,
listRevocations,
deleteRevocations,
ensureSyncRows,
listSync,
markDirty,
putSyncResult,
putCatalogue,
listCatalogue,
}

View File

@@ -0,0 +1,445 @@
// ── The authored set, and what it means for one server ────────────────────
//
// This file turns "what an operator wrote on the website" into "what one game
// server's store should contain", which is where four of phase 7's decisions
// actually live:
//
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
// id they have linked, here, at the moment of the push.
// D29 every authored row carries a scope — one server, or `*` for the fleet —
// and a server sees only what names it.
// D30 groups travel as groups. Membership is a separate wire fact from the
// permissions the group carries, because the game stores them separately
// and one of the two can fail on its own (§12.2 rule 4).
// D31 the difference between the desired set and what this site has already
// pushed is what gets retired. Anything else in the store is drift, and
// drift is reported rather than undone.
//
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
// usual one and earns its keep twice over here: the whole of the interesting
// logic is a pure function of four tables, so it is tested without a game, a
// sidecar, or a database.
const crypto = require('node:crypto')
const db = require('./permissions.db')
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
const FLEET = '*'
/**
* Permission and group names, as both frameworks store them.
*
* Lowercased on the way in, because the store lowers them and a site that did
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
* grant as drift for ever.
*/
function normaliseName(value) {
return String(value || '').trim().toLowerCase()
}
/** Whether a scope reaches a server. */
function inScope(scope, serverId) {
return scope === FLEET || scope === serverId
}
/**
* Everything the authoring screen renders, in one read.
*
* Assembled here rather than in SQL because the shape is a tree — a group with
* its permissions and its members — and the alternative is either four round
* trips per group or one join that repeats every group row once per member.
*/
async function overview() {
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listSync(),
db.listDrift(),
db.listCatalogue(),
])
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
for (const row of groupPermissions) {
const group = byGroup.get(row.groupName)
if (group) group.permissions.push(row.permission)
}
// A member with two linked Steam accounts arrives as two rows from the join,
// and is one person on the screen — holding BOTH accounts, not the first one
// the join happened to return. The screen needs all of them: a membership is
// pushed per account, and it can be waiting on one while it landed on another.
const memberByKey = new Map()
for (const row of members) {
const group = byGroup.get(row.groupName)
if (!group) continue
const key = `${row.groupName}:${row.userId}`
let member = memberByKey.get(key)
if (!member) {
member = {
userId: row.userId,
username: row.username,
accounts: [],
addedAt: row.addedAt,
}
memberByKey.set(key, member)
group.members.push(member)
}
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
}
return {
groups: [...byGroup.values()],
grants: collapseGrants(grants),
servers: sync.map(shapeSync),
drift,
catalogue: catalogueByPermission(catalogue),
}
}
/**
* One row per grant, not one per linked account.
*
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
* what the push wants and the opposite of what a screen wants.
*/
function collapseGrants(rows) {
const byId = new Map()
for (const row of rows) {
const existing = byId.get(row.id)
if (!existing) {
byId.set(row.id, {
id: row.id,
userId: row.userId,
username: row.username,
permission: row.permission,
scope: row.scope,
source: row.source,
note: row.note,
grantedAt: row.grantedAt,
accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [],
})
continue
}
if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null })
}
return [...byId.values()]
}
/**
* The sync row as a client reads it.
*
* `report` is stored as the JSON the game sent and parsed here rather than on the
* way in, so a report this build cannot read is a rendering problem on one
* screen instead of a write that failed.
*/
function shapeSync(row) {
let report = null
if (row.report) {
try {
report = JSON.parse(row.report)
} catch {
report = null
}
}
return {
serverId: row.serverId,
state: row.state,
dirty: Boolean(row.dirty),
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
lastAttemptAt: row.lastAttemptAt,
lastOkAt: row.lastOkAt,
error: row.error || null,
report,
}
}
/** Which servers know each permission name — the form's option source, and its warning label. */
function catalogueByPermission(rows) {
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
byPermission.get(row.permission).push(row.serverId)
}
return [...byPermission.entries()]
.map(([permission, servers]) => ({ permission, servers }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* ── What one person holds, as that person reads it ────────────────────────
*
* The admin overview answers *who holds what*; this answers *what do I hold*,
* and it is a different shape rather than a filtered one. Three things make it
* different:
*
* 1. **The scope arithmetic is answered here, not sent.** A client handed
* `scope: '*'` would have to know what the fleet is and re-implement
* `inScope` to say anything useful, and then there would be two of it. Each
* entry carries the servers it actually reaches, already resolved.
* 2. **`live` is per server and it is the pushed ledger, not the authored
* row.** A grant made on the website is not a privilege in a game until a
* sync confirmed it, and phase 7 is careful never to record a push that
* silently did nothing (an unregistered permission, a store that has never
* seen the player). So "waiting" here means waiting, and saying otherwise
* would be the site claiming to have given something it has not.
* 3. **Nothing says WHY it is waiting.** Which permission names a server's
* loaded plugins registered is an operator's diagnosis and an inventory of
* what is installed; a player gets the honest state, not the reason.
*
* Every read is scoped to the caller in SQL, and the pushed rows are looked up
* by the caller's OWN Steam ids — so a person with no linked account correctly
* sees entitlements that reach nobody yet, rather than nothing at all (the
* mistake phase 7 shipped on the admin user page, §20.5).
*/
async function forPlayer(userId, steamIds, serverRows) {
const [groups, groupPermissions, grants, pushed] = await Promise.all([
db.listGroupsForUser(userId),
db.listGroupPermissions(),
db.listGrants({ userId }),
db.listPushedForSteamIds(steamIds),
])
const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id }))
// `kind:object` -> the servers a row of ours landed on. The subject is one of
// this caller's own Steam ids by construction, so it does not enter the key:
// an entitlement is live for the person if it is live for any account they
// hold, which is the same thing the game sees.
const live = new Map()
for (const row of pushed) {
const key = `${row.kind}:${normaliseName(row.object)}`
if (!live.has(key)) live.set(key, new Set())
live.get(key).add(row.serverId)
}
/** The servers a scope reaches, each marked with whether it is there yet. */
function reach(scope, key) {
const landed = live.get(key) || new Set()
return servers
.filter((server) => inScope(scope, server.id))
.map((server) => ({ ...server, live: landed.has(server.id) }))
}
const permissionsByGroup = new Map()
for (const row of groupPermissions) {
if (!permissionsByGroup.has(row.groupName)) permissionsByGroup.set(row.groupName, [])
permissionsByGroup.get(row.groupName).push(normaliseName(row.permission))
}
return {
groups: groups.map((group) => ({
name: group.name,
title: group.title || group.name,
scope: group.scope,
since: group.addedAt,
permissions: (permissionsByGroup.get(group.name) || []).sort(),
reach: reach(group.scope, `member:${normaliseName(group.name)}`),
})),
// `collapseGrants` first: the join multiplies a grant by the accounts its
// holder has linked, and this caller may hold two.
grants: collapseGrants(grants)
.map((grant) => ({
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note,
since: grant.grantedAt,
reach: reach(grant.scope, `grant:${normaliseName(grant.permission)}`),
}))
.sort((a, b) => a.permission.localeCompare(b.permission)),
}
}
/**
* The whole authored set, read once, in the shape the per-server build wants.
*
* Read once per sync tick rather than once per server: six servers is six
* different answers derived from one set of tables, and re-reading them per
* server is six times the queries for the same rows.
*/
async function readAuthored() {
const [groups, groupPermissions, members, grants, links] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listLinks(),
])
const steamIdsByUser = new Map()
for (const link of links) {
if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, [])
steamIdsByUser.get(link.userId).push(link.steamId)
}
return { groups, groupPermissions, members, grants, steamIdsByUser }
}
/**
* What one server's store should contain, and the rows that say so.
*
* Returns three things the caller needs together and must not compute twice:
*
* `payload` what goes on the wire
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
* `hash` a stable digest of `rows`, which is how the loop knows nothing
* has changed without asking a game server
*
* **A user with no linked Steam account contributes nothing and is not an
* error.** They are authored against perfectly well and reach nobody until they
* link — which the admin screen says out loud, because a grant that reaches
* nothing looks exactly like one that worked.
*/
function buildDesired(serverId, authored) {
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
const groupNames = new Set(scopedGroups.map((group) => group.name))
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const managed = new Set()
const rows = []
for (const group of scopedGroups)
rows.push({ kind: 'group', subject: group.name, object: '' })
for (const row of groupPermissions) {
if (!groupNames.has(row.groupName)) continue
const permission = normaliseName(row.permission)
permissionsByGroup.get(row.groupName).push(permission)
managed.add(permission)
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
}
const seenMember = new Set()
for (const row of members) {
if (!groupNames.has(row.groupName)) continue
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${row.groupName}:${steamId}`
if (seenMember.has(key)) continue
seenMember.add(key)
membersByGroup.get(row.groupName).push(steamId)
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
}
}
const permissionsBySteamId = new Map()
const seenGrant = new Set()
for (const row of grants) {
if (!inScope(row.scope, serverId)) continue
const permission = normaliseName(row.permission)
// Managed whether or not it reaches anybody: the namespace is what makes a
// hand grant of this permission to somebody else show up as drift, and a
// grant whose holder has linked nothing would otherwise silently narrow it.
managed.add(permission)
// **Resolved from the link map, not from the row.** `listGrants` joins the
// links and therefore repeats a grant once per linked account, which would
// give the right answer here by accident — until somebody changes that query
// and one of a person's two accounts quietly stops being granted. The map is
// the same source the members above use, and it says what it means.
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) continue
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push({ kind: 'grant', subject: steamId, object: permission })
}
}
const payload = {
groups: scopedGroups.map((group) => ({
name: group.name,
title: group.title || group.name,
rank: group.rank,
permissions: permissionsByGroup.get(group.name),
members: membersByGroup.get(group.name),
})),
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
steamId,
permissions,
})),
managed: [...managed].sort(),
}
return { payload, rows, hash: hashRows(rows) }
}
/**
* A digest of the desired set.
*
* Sorted before hashing, because the rows come out of several queries in an
* order nothing guarantees — an unsorted digest would differ between two reads
* of an unchanged set and push to every game server on every tick.
*/
function hashRows(rows) {
const canonical = rows
.map((row) => `${row.kind}${row.subject}${row.object}`)
.sort()
.join('\n')
return crypto.createHash('sha256').update(canonical).digest('hex')
}
/** A row's identity, for set arithmetic against what was pushed. */
const rowKey = (row) => `${row.kind}${row.subject}${row.object}`
/**
* What this site put in a server and has since withdrawn.
*
* `pushed desired`, and it is the one calculation that cannot be replaced by
* asking the game: a name in the store that is not in the desired set is either
* something the site retired or something a human granted, and those have
* opposite correct answers (D31). Only the pushed ledger tells them apart.
*/
function retirements(pushed, desiredRows) {
const desired = new Set(desiredRows.map(rowKey))
return pushed.filter((row) => !desired.has(rowKey(row)))
}
module.exports = {
FLEET,
normaliseName,
inScope,
overview,
forPlayer,
readAuthored,
buildDesired,
retirements,
hashRows,
rowKey,
collapseGrants,
shapeSync,
}

View File

@@ -0,0 +1,192 @@
// ── SQL, and nothing else ─────────────────────────────────────────────────
//
// Core's own backend is layered `router → controller → model → db`, with models
// in pairs: a `.db.js` holding the SQL and a `.model.js` holding the logic that
// calls it. The split earns its keep here for the same reason it does in core —
// the file with the queries in it has no branching to test, and the file with the
// branching in it has no database to stand up.
//
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always.
const core = require('../../core')
const SERVERS = 'rust_servers'
const STATE = 'rust_server_state'
/**
* Every configured server, in the operator's own order.
*
* **The encrypted token comes back on this read and is never returned to a
* client.** Decryption happens in the model, one layer up; this file's job is to
* fetch a column, not to decide who may see it.
*/
async function listServers({ enabledOnly = false } = {}) {
return core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
FROM ${SERVERS}
${enabledOnly ? 'WHERE enabled = 1' : ''}
ORDER BY sort_order ASC, id ASC`,
)
}
async function getServer(id) {
const rows = await core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
FROM ${SERVERS}
WHERE id = ?`,
[id],
)
return rows[0] || null
}
/**
* Create or replace a server row.
*
* **`sidecar_token_enc` is only written when a value is supplied.** An admin form
* that shows a blank token field — which is the only thing it can show, since the
* token is write-only — posts an empty string on every save that did not intend
* to change it. Writing that through would erase the credential every time an
* operator renamed a server, and the failure would present as the bridge going
* down for no reason an hour after an unrelated edit.
*/
async function upsertServer({ id, name, sidecarBaseUrl, sidecarTokenEnc, protocol, enabled, sortOrder }) {
const setToken = sidecarTokenEnc !== null && sidecarTokenEnc !== undefined
await core.query(
`INSERT INTO ${SERVERS}
(id, name, sidecar_base_url, sidecar_token_enc, protocol, enabled, sort_order, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
sidecar_base_url = VALUES(sidecar_base_url),
${setToken ? 'sidecar_token_enc = VALUES(sidecar_token_enc),' : ''}
protocol = VALUES(protocol),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
updated_at = CURRENT_TIMESTAMP`,
[id, name, sidecarBaseUrl, setToken ? sidecarTokenEnc : null, protocol, enabled ? 1 : 0, sortOrder],
)
}
async function deleteServer(id) {
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
}
/** The last thing each server said about itself, keyed by server id. */
async function listState() {
return core.query(
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
last_seen_at AS lastSeenAt, updated_at AS updatedAt
FROM ${STATE}`,
)
}
/** One server's observed state, or `null`. The single-row twin of `listState`. */
async function getState(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
last_seen_at AS lastSeenAt, updated_at AS updatedAt
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/**
* Mark a server unreachable **without forgetting what it last said**.
*
* `putState` replaces the row whole, which is right when a sidecar answered: the
* frame it answered with is the complete truth about that server. It is wrong
* when nothing answered. A refresh that cannot reach a sidecar knows exactly one
* new fact — that it could not reach it — and writing the whole row from that
* one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL.
*
* The site's whole premise is that it renders the last thing each server said
* while every server is off. A row blanked the first time a game host reboots
* cannot do that: the page loses the map, the size, the seed and the wipe, and
* what it shows is not "offline, here is what we know" but "offline, and we have
* never heard of it". It is invisible in every test that stubs a reachable
* sidecar, and it shows up as a page that was complete an hour ago.
*
* So: three columns move, and the description stays where it is.
*/
async function markUnreachable(serverId, reachable = false) {
await core.query(
`INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at)
VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable),
online = 0,
players = 0,
updated_at = CURRENT_TIMESTAMP`,
[serverId, reachable ? 1 : 0],
)
}
/**
* Replace one server's observed state.
*
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
* value, so an update writing the same numbers back — exactly what a quiet
* server looks like — leaves the timestamp where it was. The row would then
* cross the freshness window and the page would report the server offline while
* it was up and reporting normally. That is invisible to every test and shows up
* as a page that was right when you looked at it and wrong an hour later.
*/
async function putState(state) {
await core.query(
`INSERT INTO ${STATE}
(server_id, reachable, online, players, max_players, hostname, level, seed,
world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id),
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
protocol = VALUES(protocol),
raw = VALUES(raw),
-- Only a frame moves this; an unreachable write leaves it alone, which is
-- what lets a page say how long a server has been down rather than how
-- recently we failed to reach it.
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`,
[
state.serverId,
state.reachable ? 1 : 0,
state.online ? 1 : 0,
state.players || 0,
state.maxPlayers || 0,
state.hostname || null,
state.level || null,
state.seed === undefined ? null : state.seed,
state.worldSize === undefined ? null : state.worldSize,
state.bootId || null,
state.saveCreatedAt || null,
state.wipeId || null,
state.protocol === undefined ? null : state.protocol,
state.raw ? JSON.stringify(state.raw) : null,
],
)
}
module.exports = {
SERVERS,
STATE,
listServers,
getServer,
upsertServer,
deleteServer,
listState,
getState,
markUnreachable,
putState,
}

View File

@@ -0,0 +1,173 @@
// ── The logic half ────────────────────────────────────────────────────────
//
// Shapes what the database returned into what a client should see, and holds the
// one rule that matters most in this module: **what leaves this file is never the
// sidecar's credential.**
//
// It is a separate file from the SQL so that it is testable without a database,
// and the suite next door tests it that way.
//
// The other decision worth pointing at: **a module answers when the game is
// unreachable rather than failing.** The website is the internet-facing process
// and the game is not; a game being down, or a sidecar being mid-restart, is an
// ordinary Tuesday. A page that renders "offline, last seen 20 minutes ago" is
// right; a page that 500s because a socket is closed is a module that has made
// the site's availability depend on the game's.
const core = require('../../core')
const db = require('./servers.db')
const log = core.logger('servers')
// Past this, the last thing a server said stops being news and starts being
// history. Presentation, so the number lives with the code that shapes the
// response rather than in the client.
const STALE_AFTER_MS = 5 * 60 * 1000
/**
* A configured server with its token decrypted, for this module's own use.
*
* **Never hand the result of this to a controller.** It is the input to
* `sidecarClient`, and the only shape in this module that holds a plaintext
* secret.
*
* A token that will not decrypt is returned as `null` rather than throwing: the
* usual cause is a `SECRET_ENC_KEY` that changed, and the right behaviour is a
* server that reports itself unconfigured with a line in the log — not a module
* that fails to boot and takes every other server down with it.
*/
function withToken(row) {
if (!row) return null
let token = null
if (row.sidecarTokenEnc) {
try {
token = core.secretBox().decrypt(row.sidecarTokenEnc)
} catch (err) {
log.error('could not decrypt a sidecar token', { server: row.id, error: err.message })
}
}
return { id: row.id, name: row.name, baseUrl: row.sidecarBaseUrl, token, protocol: row.protocol }
}
/** Every enabled server, with tokens, for the poller. */
async function listForPolling() {
const rows = await db.listServers({ enabledOnly: true })
return rows.map(withToken)
}
/**
* The public view: every enabled server and what it last said.
*
* Nothing here is conditional on who is asking, which is the point of it being
* the public shape. What a *player* or an *admin* additionally sees is added by
* their own tier's controller, never removed by this one.
*/
async function listPublic(now = Date.now()) {
const [servers, states] = await Promise.all([db.listServers({ enabledOnly: true }), db.listState()])
const byId = new Map(states.map((s) => [s.serverId, s]))
return servers.map((row) => shapePublic(row, byId.get(row.id), now))
}
function shapePublic(row, state, now) {
const updatedAt = state && state.updatedAt ? new Date(state.updatedAt) : null
const lastSeenAt = state && state.lastSeenAt ? new Date(state.lastSeenAt) : null
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
return {
id: row.id,
name: row.name,
// A stale row cannot claim a server is up. The row says what was true when it
// was written, and nothing has written it since.
online: Boolean(state && state.online) && !stale,
players: stale ? 0 : Number(state && state.players) || 0,
maxPlayers: Number(state && state.maxPlayers) || 0,
hostname: (state && state.hostname) || null,
level: (state && state.level) || null,
worldSize: state && state.worldSize != null ? Number(state.worldSize) : null,
seed: state && state.seed != null ? Number(state.seed) : null,
// The CURRENT wipe, from the state row rather than from the newest row in
// `rust_wipes`. The two usually agree and the state row is the one that is
// right when they do not: a wipe list is derived from events that have been
// ingested, so a server that has just wiped and said nothing since has a new
// wipe id here and no row there at all.
wipeId: (state && state.wipeId) || null,
wipedAt: (state && state.saveCreatedAt) || null,
// Two timestamps, because they are two facts. `lastSeenAt` is when a frame
// last arrived and is what a page means by "last reported"; `updatedAt` is
// when this module last wrote the row, and is what `stale` is computed from.
// Reading the second as the first is what made an offline server claim it had
// reported just now, on every failed poll, for as long as it stayed down.
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
updatedAt: updatedAt ? updatedAt.toISOString() : null,
stale,
}
}
/**
* One enabled server, or `null`.
*
* It exists because `/rust/servers/:id` is a page and a page needs to be able to
* 404. A detail view built by fetching the list and finding the row in it cannot
* tell "no such server" from "a server that has said nothing" — both are an
* absence — and renders an empty page under a heading for a server that does not
* exist. Filtering happens here, where `enabled = 0` and "never configured" are
* the same answer on purpose: a disabled server is not a 403, it is not there.
*/
async function getPublic(id, now = Date.now()) {
if (!id) return null
const row = await db.getServer(id)
if (!row || !row.enabled) return null
return shapePublic(row, await db.getState(row.id), now)
}
/**
* The admin view: configuration plus reachability, and **no token**.
*
* `hasToken` rather than the token, because the credential is write-only in the
* API: the admin form accepts a new value and never shows the stored one. An
* operator still needs to know whether one is set — a blank field means both
* "unset" and "set, and not being shown you" otherwise.
*/
async function listForAdmin(now = Date.now()) {
const [servers, states] = await Promise.all([db.listServers(), db.listState()])
const byId = new Map(states.map((s) => [s.serverId, s]))
return servers.map((row) => {
const state = byId.get(row.id)
return {
// The public shape first, so the admin-only fields below cannot be
// overwritten by a key the public shape happens to share.
...shapePublic(row, state, now),
sidecarBaseUrl: row.sidecarBaseUrl,
hasToken: Boolean(row.sidecarTokenEnc),
protocol: Number(row.protocol),
enabled: Boolean(row.enabled),
sortOrder: Number(row.sortOrder),
reachable: Boolean(state && state.reachable),
bootId: (state && state.bootId) || null,
sidecarProtocol: state && state.protocol != null ? Number(state.protocol) : null,
}
})
}
/** Encrypt a token for storage. `null`/empty means "leave whatever is stored alone". */
function encryptToken(token) {
if (token === null || token === undefined || token === '') return null
return core.secretBox().encrypt(String(token))
}
module.exports = {
STALE_AFTER_MS,
withToken,
listForPolling,
listPublic,
getPublic,
listForAdmin,
shapePublic,
encryptToken,
}

View File

@@ -0,0 +1,55 @@
// ── SQL for the visibility settings ───────────────────────────────────────
//
// Two stores for one decision: the fleet default in `rust_settings`, and an
// optional per-server override on `rust_servers`. See `schema.sql` for why each
// lives where it does.
const core = require('../../core')
const SETTINGS = 'rust_settings'
const SERVERS = 'rust_servers'
/** One setting's stored value, or `null` when nobody has ever set it. */
async function getSetting(key) {
const rows = await core.query(`SELECT value FROM ${SETTINGS} WHERE setting_key = ?`, [key])
return rows[0] ? rows[0].value : null
}
async function setSetting(key, value, userId = null) {
await core.query(
`INSERT INTO ${SETTINGS} (setting_key, value, updated_by, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP`,
[key, value, userId],
)
}
/** One server's override, `null` for "inherit", or `undefined` when there is no such server. */
async function getServerPresence(serverId) {
const rows = await core.query(`SELECT presence_audience AS presence FROM ${SERVERS} WHERE id = ?`, [serverId])
return rows[0] ? rows[0].presence : undefined
}
/** Every configured server with its override, in the operator's own order. */
async function listServerPresence() {
return core.query(
`SELECT id, name, enabled, presence_audience AS presence
FROM ${SERVERS}
ORDER BY sort_order ASC, id ASC`,
)
}
/**
* Sets or clears (`null`) one server's override.
*
* Returns nothing, deliberately. `affectedRows` would look like a way to tell
* "no such server" from success, and it is not one: without `foundRows` an
* UPDATE writing the value already there reports 0, and whether core's pool sets
* that flag is core's business. The model checks existence with a read first.
*/
async function setServerPresence(serverId, value) {
await core.query(`UPDATE ${SERVERS} SET presence_audience = ? WHERE id = ?`, [value, serverId])
}
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence }

View File

@@ -0,0 +1,264 @@
// ── Who may see who is online ─────────────────────────────────────────────
//
// The org lead's rule, settled 2026-09-22: **nothing tells who is online by
// default.** It is always the lowest blast radius — staff — unless an operator
// deliberately widens it, and a COUNT of players is fine where a list of names
// is not.
//
// "Who is online" is wider than the Online tab. Every frame that says a named
// player was on the server at a given moment says it: a connect, a respawn, a
// death, a chat line, a gather tally (`catalogue.PRESENCE_KINDS`), and a
// leaderboard row's `lastSeen`, which a tally refreshes every minute while
// somebody plays. All of them sit behind this one setting.
//
// ── The audiences ─────────────────────────────────────────────────────────
//
// staff an admin or a moderator — the two roles every Team surface in
// core also means by "staff"
// signed_in any active website account
// public anybody, signed in or not
//
// Ordered, each rung implying the ones below it. The names line up with phase
// 14's map-layer switches (public / players / admin) so that one layer can take
// this over rather than sit beside it.
//
// ── Two fallbacks, deliberately asymmetric ────────────────────────────────
//
// An unrecognised VIEWER reads as the bottom rung and an unrecognised
// REQUIREMENT reads as the top one, so a value nobody expected always loses.
// One shared fallback cannot do that: whichever way it points, it fails open on
// one side. module-uo's shard visibility learned this the hard way; the rule is
// copied here rather than rediscovered.
const core = require('../../core')
const db = require('./visibility.db')
const log = core.logger('visibility')
const AUDIENCES = Object.freeze(['public', 'signed_in', 'staff'])
const RANK = new Map(AUDIENCES.map((a, i) => [a, i]))
/** The narrowest rung, and the default wherever nothing has been chosen. */
const DEFAULT_PRESENCE = 'staff'
/** The `rust_settings` key the fleet default lives under. */
const PRESENCE_KEY = 'presence.audience'
const isAudience = (value) => RANK.has(value)
// ── Who may see a clan's roster (phase 9, D48) ────────────────────────────
//
// The same rule applied to a roster: a roster says who is in a clan and, inside
// its audience, which of them is on. So it defaults to the clan's OWN members
// plus staff, and an operator widens it deliberately.
//
// members staff, and a website account linked to one of the clan's members
// signed_in any active website account
// public anybody
//
// One fleet-wide setting (D48), deliberately without a per-server override: the
// presence override exists because a PvE server may publish a roll call a PvP one
// must not, and a roster is the same answer on every server of the fleet.
//
// **Widening it widens online status too.** Core's `projectRoster` can withhold a
// roster's rows but not its fields, so there is no rung that shows who is in a
// clan and hides which of them is on. The admin page says so beside the switch.
const CLAN_AUDIENCES = Object.freeze(['public', 'signed_in', 'members'])
/** The narrowest rung, and the default until an operator chooses. */
const DEFAULT_CLAN_ROSTER = 'members'
/** The `rust_settings` key the roster audience lives under. */
const CLAN_ROSTER_KEY = 'clans.roster.audience'
const isClanAudience = (value) => CLAN_AUDIENCES.includes(value)
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
/** Does a viewer at `viewer` satisfy a requirement of `required`? */
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
/**
* The viewer's rung, re-read from the database.
*
* `getUserFromRequest` decodes a token and nothing more — the role in it is the
* role the account had when it signed in. For a gate on who may see who is
* online, that is not good enough: a moderator demoted this morning would keep
* the roll call until their token expired, and a banned account would keep
* reading it too. So the token only says WHO; the row says what they are now.
*
* Any failure resolves to `public` — the bottom rung — because an unanswerable
* question about somebody's standing must grant nothing.
*/
async function viewerLevel(req) {
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return 'public'
const user = await core.users.getById(claimed.id)
if (!user) return 'public'
if (user.status && user.status !== 'active') return 'public'
if (user.role === 'admin' || user.role === 'moderator') return 'staff'
return 'signed_in'
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return 'public'
}
}
/** A stored value as an audience, narrowing anything this build does not recognise. */
function normalise(value) {
return isAudience(value) ? value : DEFAULT_PRESENCE
}
/** The fleet default. */
async function fleetPresence() {
const stored = await db.getSetting(PRESENCE_KEY)
return stored == null ? DEFAULT_PRESENCE : normalise(stored)
}
/**
* The audience that applies to one server: its override if it has one, the
* fleet default otherwise.
*
* A server that does not exist gets the fleet default, which is the right answer
* for the routes that call this: they answer an empty list for an unknown id,
* and an empty list is empty at every rung.
*/
async function presenceFor(serverId) {
const override = await db.getServerPresence(serverId)
if (override != null) return normalise(override)
return fleetPresence()
}
/**
* Everything a public route needs in one call: may this viewer see who is on
* this server?
*
* Throws nothing. A setting that cannot be read resolves to "no" — the routes
* that ask would otherwise have to choose between a 500 and publishing names.
*/
async function canSeePresence(req, serverId) {
try {
const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
return { visible: meets(level, required), level, required }
} catch (err) {
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
}
}
/**
* The clan roster audience. An unrecognised stored word narrows to `members`,
* and a read that fails throws — every caller answers "no" on a throw, which is
* the direction a roster must fail in.
*/
async function clanRosterAudience() {
const stored = await db.getSetting(CLAN_ROSTER_KEY)
return isClanAudience(stored) ? stored : DEFAULT_CLAN_ROSTER
}
/** The admin screen's read: the fleet default and every server beside it. */
async function describe() {
const [fleet, servers, clanRoster] = await Promise.all([
fleetPresence(),
db.listServerPresence(),
clanRosterAudience(),
])
return {
audiences: [...AUDIENCES],
clans: { audiences: [...CLAN_AUDIENCES], roster: clanRoster },
presence: {
fleet,
servers: servers.map((s) => {
const override = s.presence == null ? null : normalise(s.presence)
return {
id: s.id,
name: s.name,
enabled: Boolean(s.enabled),
override,
effective: override || fleet,
}
}),
},
}
}
/**
* The admin screen's write.
*
* `fleet` is optional; `servers` maps an id to an audience, or to `null` to
* clear its override. Validated whole before anything is written, so a request
* naming one unknown server changes nothing rather than half of what it asked.
*
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
async function update({ fleet, servers, clanRoster } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
if (clanRoster !== undefined && !isClanAudience(clanRoster)) {
return {
ok: false,
status: 400,
message: `"${clanRoster}" is not a clan roster audience. Choose one of: ${CLAN_AUDIENCES.join(', ')}.`,
}
}
const changes = Object.entries(servers || {})
for (const [id, value] of changes) {
if (value !== null && !isAudience(value)) {
return { ok: false, status: 400, message: `"${value}" is not an audience for server ${id}.` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
if (clanRoster !== undefined) await db.setSetting(CLAN_ROSTER_KEY, clanRoster, userId)
for (const [id, value] of changes) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
}
// What was written, for the controller's audit row. Recorded there rather than
// here because the activity log takes the REQUEST (who, from where), and a
// model that took a request would be a model that could only be called by one.
return {
ok: true,
changed: {
...(fleet !== undefined ? { fleet } : {}),
...(clanRoster !== undefined ? { clanRoster } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
},
}
}
module.exports = {
AUDIENCES,
DEFAULT_PRESENCE,
PRESENCE_KEY,
isAudience,
CLAN_AUDIENCES,
DEFAULT_CLAN_ROSTER,
CLAN_ROSTER_KEY,
isClanAudience,
clanRosterAudience,
meets,
normalise,
viewerLevel,
fleetPresence,
presenceFor,
canSeePresence,
describe,
update,
}

1088
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "rust-module-server",
"version": "0.1.0",
"private": true,
"description": "Server half of the Rust module — routers, models and the schema fragment core loads at boot",
"license": "GPL-3.0-or-later",
"main": "index.js",
"scripts": {
"test": "node --test",
"check:imports": "node scripts/checkImports.js",
"check:bundle": "node scripts/checkBundle.js",
"swagger": "node scripts/swaggerFragment.js",
"check:swagger": "node scripts/swaggerFragment.js --check"
},
"engines": {
"node": ">=20"
},
"//dependencies": "There are none, and that is the shape to aim for: everything the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger and the middleware are all core-owned and handed over. If you do add one, remember an operator never builds: your release CI runs npm ci --omit=dev and packs server/node_modules into the tarball, so every dependency is weight in the artifact and a package the operator now runs. scripts/checkImports.js reads this file to decide what the shipped half may resolve.",
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.1.0",
"swagger-autogen": "^2.23.7"
},
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express Router and a REAL express-validator, because a fake of either would only ever test the fake - the admin router builds its validation chains at file scope, so a stubbed validator is not something it can be required with. swagger-autogen generates the OpenAPI fragment; pin it to the same major core uses, so the fragment and the spec it merges into come out of one tool."
}

342
server/permSync.js Normal file
View File

@@ -0,0 +1,342 @@
// ── Keeping a game's permission store equal to what the site authored ─────
//
// R2's whole mechanism, and it is chapter 4's board pointed the other way: the
// site is the single producer of a set, it re-sends the whole thing rather than
// a stream of edits, and the receiver reconciles. What is new is the direction —
// the module telling the game what the site knows, where every earlier phase
// asked the game what it knew.
//
// ── One verb (D32) ────────────────────────────────────────────────────────
//
// A sync sends the whole desired set and the plugin diffs it against the live
// store. The website never holds a copy of the game's permissions, which is the
// point: a second source of truth is stale the moment it lands, and the store is
// the bigger of the two sets.
//
// The delta the site DOES compute is the one the game cannot: what this site put
// there and has since withdrawn (`retirements`). A name in the store that is not
// in the desired set is either that, or a hand edit — and only the pushed ledger
// can tell them apart (D31).
//
// ── When it runs ──────────────────────────────────────────────────────────
//
// Every tick asks a cheap question — does the digest of the desired set still
// equal what this server last confirmed — and does nothing when the answer is
// yes. A sync therefore happens when:
//
// • an operator changed something (the dirty flag, and the digest behind it)
// • the game restarted or wiped (a new boot id or wipe id: the store may have
// been emptied, and R2's promise is that a wipe is not a data-loss event)
// • a permission hook fired in the game that we did not cause (`ingest.js`
// marks the server dirty; the authoritative answer is this sync's report)
// • the audit interval elapsed — the backstop that finds drift on a quiet
// server nobody has touched
// • the last attempt failed, after a backoff
//
// ── What it never does ────────────────────────────────────────────────────
//
// It does not remove a grant it did not make (D31), it does not invent a
// permission the server has not registered (D33), and it does not treat a
// silent sidecar as a reason to forget anything. A server that is unreachable
// keeps its retirements and its revocations until it comes back.
const core = require('./core')
const db = require('./model/permissions/permissions.db')
const model = require('./model/permissions/permissions.model')
const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db')
const sidecar = require('./sidecarClient')
const log = core.logger('permissions')
/** How often the loop asks whether anything needs pushing. */
const TICK_MS = 30 * 1000
/**
* How long a server may go without a full reconciliation, however quiet it is.
*
* The digest comparison is what keeps the loop cheap, and on its own it would
* also mean a server whose store somebody edited by hand is never asked about
* again. This is the interval at which the question gets asked anyway.
*/
const AUDIT_MS = 15 * 60 * 1000
/** How long to leave a failing server alone before trying again. */
const FAIL_BACKOFF_MS = 2 * 60 * 1000
/**
* The most rows one sync may carry.
*
* Below the sidecar's line cap and below the plugin's operation ceiling, so the
* refusal happens here — where it can name the server and reach an operator —
* rather than as a `413` or a `too-large` from two processes away.
*/
const MAX_ROWS = 15000
let timer = null
function start() {
if (timer) return
timer = setInterval(() => {
tick().catch((err) => log.error('permission sync tick failed', { error: err.message }))
}, TICK_MS)
if (timer.unref) timer.unref()
}
function stop() {
if (!timer) return
clearInterval(timer)
timer = null
}
/**
* One pass over every enabled server.
*
* The authored set is read ONCE and handed to each server's build: six servers
* are six different answers derived from the same four tables, and re-reading
* them per server is six times the queries for identical rows.
*/
async function tick({ force = null } = {}) {
await db.ensureSyncRows()
const [rows, state, sync, authored] = await Promise.all([
servers.listForPolling(),
serversDb.listState(),
db.listSync(),
model.readAuthored(),
])
const syncById = new Map(sync.map((row) => [row.serverId, row]))
const stateById = new Map(state.map((row) => [row.serverId, row]))
// `allSettled`, for the same reason the board poll uses it: one unreachable
// host must not stop the other five being reconciled.
await Promise.allSettled(
rows
.filter((server) => force === null || force === server.id)
.map((server) =>
syncOne(server, {
authored,
sync: syncById.get(server.id) || null,
state: stateById.get(server.id) || null,
force: force !== null,
}),
),
)
}
/**
* Whether this server needs a push right now.
*
* Returns a reason rather than a boolean, because the reason is worth logging:
* "why did the website just write to my game server" is a question an operator
* asks, and `wipe` and `drift` are very different answers.
*/
function reasonToSync({ desiredHash, sync, state, force }) {
if (force) return 'requested'
if (!sync) return 'first'
if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) {
return null
}
if (sync.state !== 'ok') return 'retry'
if (desiredHash !== sync.syncedHash) return 'changed'
if (sync.dirty) return 'dirty'
const bootId = state && state.bootId ? state.bootId : null
const wipeId = state && state.wipeId ? state.wipeId : null
// A restart or a wipe is the case R2 exists for: the game may have forgotten
// everything, and the site has not.
if (bootId && bootId !== sync.bootId) return 'restart'
if (wipeId && wipeId !== sync.wipeId) return 'wipe'
if (!sync.lastAttemptAt || age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit'
return null
}
function age(value) {
const at = value instanceof Date ? value.getTime() : new Date(value).getTime()
return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER
}
async function syncOne(server, { authored, sync, state, force }) {
const desired = model.buildDesired(server.id, authored)
const reason = reasonToSync({ desiredHash: desired.hash, sync, state, force })
if (!reason) return null
const [pushed, revocations] = await Promise.all([
db.listPushed(server.id),
db.listRevocations(server.id),
])
const retirements = model.retirements(pushed, desired.rows)
const retire = [
...retirements.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
]
const bootId = state && state.bootId ? state.bootId : null
const wipeId = state && state.wipeId ? state.wipeId : null
if (desired.rows.length + retire.length > MAX_ROWS) {
// Refused here rather than sent: the sidecar would answer `413` and the
// plugin would answer `too-large`, and neither of those messages reaches the
// person who has to make the set smaller.
const error = `the permission set is too large to push (${desired.rows.length + retire.length} rows, limit ${MAX_ROWS})`
log.error('permission sync refused', { server: server.id, rows: desired.rows.length })
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error,
})
return 'too-large'
}
log.info('syncing permissions', {
server: server.id,
reason,
rows: desired.rows.length,
retire: retire.length,
})
const result = await sidecar.permSync(server, {
setId: desired.hash,
groups: desired.payload.groups,
grants: desired.payload.grants,
managed: desired.payload.managed,
retire,
})
if (!result.ok) {
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error: result.status,
})
return result.status
}
const report = result.data || {}
// The plugin refuses a whole sync with `perm.error` — `busy` while an earlier
// one is still draining, `too-large` past its own ceiling. Both are answers
// rather than transport failures, exactly like a refused link code, so they
// arrive as a 200 and are told apart by `kind`.
if (report.kind === 'perm.error') {
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error: `the game refused the sync: ${report.reason || 'unknown'}`,
})
return report.reason || 'refused'
}
await applyReport(server, { desired, retire, report, bootId, wipeId })
return 'ok'
}
/**
* Record what the game said it did.
*
* Three writes, and the order matters only in that all three are safe to repeat:
* a sync that crashes here is re-run next tick and reaches the same place, which
* is the property that lets this loop be the only writer.
*/
async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || [])
// A grant naming a permission this server has not registered did NOT land —
// `GrantUserPermission` no-ops silently for an unregistered name, which is
// why the plugin pre-checks and says so. Recording it as pushed would make the
// site believe it had given a privilege it had not.
//
// The same for a member the store could not place: the membership is waiting
// on their first connection, and it is not in the game yet.
const landed = desired.rows.filter((row) => {
if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object)
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
return true
})
await db.addPushed(server.id, landed)
// Everything retired is gone from the game whether the plugin removed it or
// found it already absent, so it stops being something this site put there.
await db.removePushed(server.id, retire)
const revocations = await db.listRevocations(server.id)
await db.deleteRevocations(revocations.map((row) => row.id))
await db.replaceDrift(server.id, (report.foreign || []).map((row) => ({
kind: String(row.kind || ''),
subject: String(row.subject || ''),
object: String(row.object || ''),
})))
await db.putSyncResult(server.id, {
state: 'ok',
desiredHash: desired.hash,
syncedHash: desired.hash,
bootId,
wipeId,
report: JSON.stringify(report),
error: null,
})
// The option source, refreshed from the same server that just answered. It is
// a second round trip and it is worth it: the form must not offer a name that
// stopped being registered when somebody uninstalled a plugin, because a grant
// against one is a privilege nobody ever gets and nothing ever reports.
const catalogue = await sidecar.permCatalogue(server)
if (catalogue.ok && catalogue.data && Array.isArray(catalogue.data.permissions)) {
await db.putCatalogue(
server.id,
catalogue.data.permissions.map(model.normaliseName).filter(Boolean),
)
}
log.info('permissions synced', {
server: server.id,
applied: report.applied,
unresolved: (report.unresolved || []).length,
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
})
}
module.exports = {
TICK_MS,
AUDIT_MS,
FAIL_BACKOFF_MS,
MAX_ROWS,
start,
stop,
tick,
syncOne,
reasonToSync,
applyReport,
}

View File

@@ -0,0 +1,331 @@
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
//
// R18. Four routes: list the tree, read a file, write a file, read what has
// been written lately. Every one of them is a live round trip to a game host —
// nothing here is cached, because a cached config is an edit somebody made over
// SSH that this website then silently overwrote.
//
// ── The write is three steps and the order is the whole design ────────────
//
// 1. **Re-read the file from the host.** Form edits are spliced into the text
// that is on disk *now*, not into the text a browser was holding. The
// version the browser presents is checked against the fresh one, and a
// mismatch is a conflict rather than an overwrite.
// 2. **Compose the new bytes here** (D35). The browser sends pointers and
// literals; `configEdit` splices them. It never parses and re-serialises,
// because that is how every untouched `1.0` becomes `1` and how a plugin
// fails to come back from its reload.
// 3. **Hand the whole file to the plugin**, which version-checks it again,
// backs the old one up, writes, reloads, and rolls the write back if the
// plugin does not announce itself. That last part is the feature; this file
// reports it.
//
// ── What the outcome means ────────────────────────────────────────────────
//
// A `config.report` with `rolledBack: true` is a SUCCESSFUL round trip carrying
// bad news: the edit was undone, the plugin is back on its old config, and the
// admin needs to see the log line that says why. It is not a 5xx, and treating
// it as one would lose the only diagnosis available.
const core = require('../../core')
const configEdit = require('../../configEdit')
const db = require('../../model/config/config.db')
const model = require('../../model/config/config.model')
const servers = require('../../model/servers/servers.model')
const serversDb = require('../../model/servers/servers.db')
const sidecar = require('../../sidecarClient')
const log = core.logger('admin:config')
/** Reads the server row with its token, or answers 404 once, here. */
async function serverOr404(req, res) {
const row = servers.withToken(await serversDb.getServer(req.params.serverId))
if (!row) {
res.status(404).json({ message: 'No such server' })
return null
}
return row
}
/**
* Turns a sidecar failure into a sentence an operator can act on.
*
* The statuses are the ones `sidecarClient` produces, and each names a different
* fix: nothing configured, no credential, the wrong protocol, a game that is
* down, a game that is up and silent.
*/
function unreachable(res, reply, what) {
const messages = {
'not-configured': 'That server has no sidecar URL configured',
'no-token': 'That server has no sidecar token configured',
'protocol-mismatch': 'That servers sidecar speaks a different protocol version',
unauthorized: 'That servers sidecar rejected the stored token',
timeout: 'That servers sidecar did not answer in time',
'http-503': 'The game is not connected to that servers sidecar',
'http-504': 'The game did not answer in time',
}
const message = messages[reply.status] || `Could not ${what}`
return res.status(503).json({ message, status: reply.status })
}
/** Every settings file on one host, grouped by the plugin that probably owns it. */
async function listFiles(req, res) {
const server = await serverOr404(req, res)
if (!server) return undefined
const reply = await sidecar.configFiles(server)
if (!reply.ok) return unreachable(res, reply, 'read that servers configuration')
if (reply.data && reply.data.kind === 'config.error') {
return res.status(502).json({ message: refusalMessage(reply.data) })
}
return res.json(model.shapeCatalogue(reply.data))
}
/** One file: its text, and the reading of it the form is drawn from. */
async function readFile(req, res) {
const path = String(req.query.path || '')
if (!model.isPlausiblePath(path)) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
const server = await serverOr404(req, res)
if (!server) return undefined
const [fileReply, catalogueReply] = await Promise.all([
sidecar.configFile(server, path),
// Asked alongside, because whether this file is OURS decides whether three
// of its keys are locked (D38) — and the answer is the plugin's own name,
// never a filename this module matched on.
sidecar.configFiles(server),
])
if (!fileReply.ok) return unreachable(res, fileReply, 'read that file')
if (fileReply.data && fileReply.data.kind === 'config.error') {
return res.status(refusalStatus(fileReply.data)).json({ message: refusalMessage(fileReply.data) })
}
const self = catalogueReply.ok && catalogueReply.data ? catalogueReply.data.self : null
return res.json(model.shapeFile(fileReply.data, { self }))
}
/**
* Save one file, and reload whatever owns it.
*
* Two tiers in one route, because they are one action with two ways of saying
* what changed: `edits` is the generated form, `text` is the raw editor.
*/
async function writeFile(req, res) {
const path = String(req.body.path || '')
const tier = Array.isArray(req.body.edits) ? 'form' : 'raw'
const reload = req.body.reload ? String(req.body.reload) : null
const serverId = req.params.serverId
if (!model.isPlausiblePath(path)) {
return res.status(400).json({ message: 'That is not a configuration path' })
}
const server = await serverOr404(req, res)
if (!server) return undefined
const [current, catalogue] = await Promise.all([
sidecar.configFile(server, path),
sidecar.configFiles(server),
])
if (!current.ok) return unreachable(res, current, 'read that file')
if (current.data && current.data.kind === 'config.error') {
return res.status(refusalStatus(current.data)).json({ message: refusalMessage(current.data) })
}
const onDisk = current.data
const self = catalogue.ok && catalogue.data ? catalogue.data.self : null
const locked = model.lockedKeysFor(path, self)
// The browser's version against what is on the host right now. The plugin
// checks this again before it writes — this check exists so that a conflict
// is reported with the current file in hand, which is what a person needs to
// merge their change rather than retype it.
if (String(req.body.version || '') !== String(onDisk.version)) {
return res.status(409).json({
message: 'That file changed on the server since you opened it',
current: model.shapeFile(onDisk, { self }),
})
}
let text
let changes
if (tier === 'form') {
const applied = configEdit.applyEdits(onDisk.text, req.body.edits, { locked })
if (applied.error) return res.status(400).json({ message: applied.error })
text = applied.text
changes = applied.changes
} else {
text = String(req.body.text || '')
try {
configEdit.scan(text)
} catch (err) {
return res.status(400).json({ message: `That is not valid JSON: ${err.message}` })
}
const broken = model.lockedChanges(onDisk.text, text, locked)
if (broken.length > 0) {
return res.status(400).json({
message: `${broken.join(', ')} cannot be changed from the website`,
locked: broken.map((key) => ({ key, reason: model.LOCKED_REASON[key.toLowerCase()] || null })),
})
}
// A raw save records that the document was replaced rather than a field
// list, because that is what happened. Pretending to know which keys moved
// would mean diffing two documents and reporting a guess as an audit fact.
changes = text === onDisk.text ? [] : [{ path: '(whole file)', from: null, to: null }]
}
if (changes.length === 0) {
return res.json({ changed: false, version: onDisk.version })
}
const reply = await sidecar.configWrite(server, {
files: [{ path, version: onDisk.version, text }],
...(reload ? { reload } : {}),
})
if (!reply.ok) {
await record(req, {
serverId,
path,
self,
reload,
tier,
outcome: 'unreachable',
changes,
versionBefore: onDisk.version,
detail: reply.status,
})
return unreachable(res, reply, 'write that file')
}
if (reply.data && reply.data.kind === 'config.error') {
await record(req, {
serverId,
path,
self,
reload,
tier,
outcome: 'refused',
changes,
versionBefore: onDisk.version,
detail: refusalMessage(reply.data),
})
return res.status(refusalStatus(reply.data)).json({ message: refusalMessage(reply.data) })
}
const report = model.summariseReport(reply.data)
const after = report && report.files[0] ? report.files[0].version : null
await record(req, {
serverId,
path,
self,
reload,
tier,
outcome: report && report.rolledBack ? 'rolled-back' : 'applied',
reloaded: Boolean(report && report.reloaded),
changes,
versionBefore: onDisk.version,
versionAfter: after,
detail: report ? report.reason : null,
})
// 200 either way. A rollback is a round trip that worked and an edit that did
// not, and the body says which — collapsing it into a 5xx would throw away
// the log line that explains it.
return res.json({ changed: true, report })
}
/** What has been written to this server's configuration lately, and by whom. */
async function history(req, res) {
try {
return res.json({ writes: await db.recentWrites(req.params.serverId, req.query.limit) })
} catch (err) {
log.error('failed to read the configuration history', { error: err.message })
return res.status(500).json({ message: 'Failed to read the configuration history' })
}
}
/** One audit row, plus the activity entry core owns. Never lets a logging failure fail a save. */
async function record(req, row) {
try {
await db.recordWrite({
...row,
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path),
reloadTarget: row.reload,
userId: req.user ? req.user.id : null,
})
await core.activity.log({
req,
action: 'rust.config.write',
detail: {
server: row.serverId,
path: row.path,
tier: row.tier,
outcome: row.outcome,
reload: row.reload || null,
fields: Array.isArray(row.changes) ? row.changes.length : 0,
},
})
} catch (err) {
log.error('failed to record a configuration write', { path: row.path, error: err.message })
}
}
function pluginOf(path) {
return String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
}
/** A `config.error` frame as a sentence. */
function refusalMessage(frame) {
const reasons = {
busy: 'Another configuration write on that server is still finishing',
conflict: 'That file changed on the server since you opened it',
invalid: 'The game refused that file: it is not valid JSON',
missing: 'That file is not on that server',
path: 'That path is not inside the servers configuration directory',
'too-large': 'That file is larger than the bridge will carry',
'too-many': 'That save touches too many files',
'reload-self': 'The bridge plugin cannot be reloaded from the website',
'reload-failed': 'The game could not reload that plugin',
unwritable: 'The game could not write that file',
unreadable: 'The game could not read that file',
'no-root': 'That framework reports no configuration directory',
}
const base = reasons[frame.reason] || 'The game refused that configuration change'
return frame.detail ? `${base} (${frame.detail})` : base
}
/** A refusal's status: the caller's fault where it is, the far end's where it is not. */
function refusalStatus(frame) {
if (frame.reason === 'conflict') return 409
if (frame.reason === 'busy') return 409
if (['path', 'missing', 'invalid', 'too-large', 'too-many', 'reload-self'].includes(frame.reason)) return 400
return 502
}
module.exports = { listFiles, readFile, writeFile, history, refusalMessage, refusalStatus }

View File

@@ -0,0 +1,108 @@
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/config…`. A **nested** `use()` rather than a second mount,
// because a mount prefix is one path segment — core's own check is
// `/^\/[a-z0-9][a-z0-9-]*$/`, so `/rust/config` could never be declared in
// `module.json`. (The OpenAPI generator follows the require and prefixes these
// correctly regardless, which phase 7 established the hard way.)
//
// **Every route is `requireRole('admin')`**, on top of the tier's own gate. This
// is a website form writing files onto a game host and reloading its plugins,
// which is the most powerful thing this module can do to somebody's server.
// There is still no module-declared site permission at MODULE_API 1.10.0 — R18
// asked for one and hits the same wall phase 7 did — so role is the whole of the
// available vocabulary, and `admin` is the honest choice within it.
const core = require('../../core')
const express = core.express
const config = require('./config.controller')
const { requireRole, validate } = core.middleware
const { body, param, query } = core.validator
const configRouter = express.Router()
/** A server id, as every other route in this module spells it. */
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
/**
* A plugin name to reload.
*
* Shape only. Whether the name is loaded — and whether it is the bridge itself,
* which cannot reload itself without closing the link carrying the answer — is
* the plugin's decision, because it is the only process that knows.
*/
const PLUGIN_NAME = /^[A-Za-z0-9_.-]{1,128}$/
configRouter.get(
'/:serverId/files',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Every plugin configuration file on one server'
// #swagger.description = 'A live recursive walk of the game hosts configuration directory, grouped by the plugin each file probably belongs to, plus every plugin currently loaded. The root comes from the mod framework, so it is `oxide/config` on Oxide and `carbon/configs` on Carbon. Files past the size limit are listed and marked un-editable rather than hidden. The games data directory is never walked.'
/* #swagger.responses[200] = { description: 'The configuration tree and the loaded plugins' } */
/* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
validate,
config.listFiles,
)
configRouter.get(
'/:serverId/file',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'One configuration file'
// #swagger.description = 'The files text, the version a save must present back, and the field list the generated form is drawn from — types, keys, and which values are credentials. A file that is already broken on disk still opens, with the parse error, because the raw tier is the only thing that can fix it.'
/* #swagger.responses[200] = { description: 'The file and the reading of it' } */
/* #swagger.responses[400] = { description: 'Not a configuration path' } */
/* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
query('path').isString().isLength({ min: 1, max: 255 }),
validate,
config.readFile,
)
configRouter.post(
'/:serverId/file',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Save a configuration file and reload its plugin'
// #swagger.description = 'Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin does not come back — which is answered 200 with `report.rolledBack`, because a rollback is a round trip that worked and an edit that did not.'
/* #swagger.responses[200] = { description: 'What happened: applied, or rolled back with the reason' } */
/* #swagger.responses[400] = { description: 'Invalid body, an edit the form may not make, or a locked key' } */
/* #swagger.responses[409] = { description: 'The file changed on the host since it was read' } */
/* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
body('path').isString().isLength({ min: 1, max: 255 }),
body('version').isString().isLength({ min: 1, max: 64 }),
body('reload').optional({ values: 'falsy' }).matches(PLUGIN_NAME),
// One tier or the other, never both and never neither. `edits` carries the
// form's pointers and literals; `text` is the whole document.
body('edits').optional().isArray({ max: 500 }),
body('text').optional().isString().isLength({ max: 262144 }),
body().custom((value) => {
const hasEdits = Array.isArray(value.edits)
const hasText = typeof value.text === 'string'
if (hasEdits === hasText) throw new Error('send either edits or text')
return true
}),
validate,
config.writeFile,
)
configRouter.get(
'/:serverId/writes',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Recent configuration writes'
// #swagger.description = 'The audit trail for one server: who changed which field, from what to what, whether the plugin reloaded, and whether the change was rolled back. Refused and unreachable attempts are recorded too — an operator asking why a setting is not what they set needs to see that somebody tried. Values of credential-shaped fields are never stored.'
/* #swagger.responses[200] = { description: 'The recent writes, newest first' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
query('limit').optional().isInt({ min: 1, max: 200 }),
validate,
config.history,
)
module.exports = configRouter

View File

@@ -0,0 +1,424 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// The authoring surface for R2. Everything here writes to the site's own tables
// and marks the affected servers dirty; nothing here talks to a game. The push
// is `permSync.js`'s loop, which is deliberate — a form that wrote to six game
// hosts inside the request would fail differently for each of them and have no
// honest status code to answer with.
//
// **The one exception is "sync now"**, which runs the loop's pass for one server
// and waits for it. It exists because an operator who has just changed something
// wants to see it land, and because waiting thirty seconds to find out that a
// server is unreachable is a bad way to learn it.
//
// Every write logs an activity row. These rows decide who may do what inside
// somebody's game server, which is the one thing on this module's admin tier
// more consequential than the sidecar credential.
const core = require('../../core')
const db = require('../../model/permissions/permissions.db')
const model = require('../../model/permissions/permissions.model')
const permSync = require('../../permSync')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin:permissions')
/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
async function overview(req, res) {
try {
res.json(await model.overview())
} catch (err) {
log.error('failed to read the permission model', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission model' })
}
}
/**
* Create or update a group.
*
* The permission list is part of the same write, because that is how the form
* edits it: a group and what it carries are one idea on the screen, and two
* requests would leave a group briefly carrying the wrong set.
*/
async function putGroup(req, res) {
const name = model.normaliseName(req.params.name)
const scope = String(req.body.scope || model.FLEET)
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
const previous = await db.getGroup(name)
await db.upsertGroup({
name,
title: String(req.body.title || name),
rank: Number(req.body.rank) || 0,
scope,
})
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
await db.setGroupPermissions(name, permissions)
// Both scopes: a group that moved from one server to another has to be
// retired from where it was as well as applied where it now is, and only the
// old scope knows the first half.
await db.markDirty(scope)
if (previous && previous.scope !== scope) await db.markDirty(previous.scope)
await core.activity.log({
req,
action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create',
detail: { group: name, scope, permissions: permissions.length },
})
return res.status(204).end()
} catch (err) {
log.error('failed to save a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to save that group' })
}
}
async function deleteGroup(req, res) {
const name = model.normaliseName(req.params.name)
try {
const existing = await db.getGroup(name)
if (!existing) return res.status(404).json({ message: 'No such group' })
await db.deleteGroup(name)
await db.markDirty(existing.scope)
await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { group: name } })
return res.status(204).end()
} catch (err) {
log.error('failed to delete a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to delete that group' })
}
}
async function addMember(req, res) {
const name = model.normaliseName(req.params.name)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
await db.addGroupMember(name, userId, req.user ? req.user.id : null)
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.add',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
// A user id that names nobody fails on the foreign key rather than on a
// check of our own: the row is the constraint, and one round trip is
// cheaper than two.
log.error('failed to add a member', { group: name, userId, error: err.message })
return res.status(400).json({ message: 'That account could not be added to the group' })
}
}
async function removeMember(req, res) {
const name = model.normaliseName(req.params.name)
const userId = Number(req.params.userId)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const removed = await db.removeGroupMember(name, userId)
if (!removed) return res.status(404).json({ message: 'That account is not in the group' })
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.remove',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a member', { group: name, userId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that account from the group' })
}
}
/**
* Grant one permission to one person.
*
* `source` is fixed at `admin` here and is not accepted from the body: the
* column exists so phase 13's event actions can write their own rows through the
* same table, and a route that let a caller choose would make "who gave this"
* unanswerable the first time somebody passed the wrong string.
*/
async function addGrant(req, res) {
const permission = model.normaliseName(req.body.permission)
const scope = String(req.body.scope || model.FLEET)
let userId = null
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
const { inserted } = await db.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: req.body.note ? String(req.body.note).slice(0, 255) : null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await db.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { userId, permission, scope },
})
}
return res.status(inserted ? 201 : 200).json({ granted: inserted })
} catch (err) {
log.error('failed to grant', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
async function removeGrant(req, res) {
const id = Number(req.params.id)
try {
const grant = await db.getGrant(id)
if (!grant) return res.status(404).json({ message: 'No such grant' })
await db.deleteGrant(id)
await db.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId: grant.userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to revoke a grant', { grant: id, error: err.message })
return res.status(500).json({ message: 'Failed to remove that grant' })
}
}
/**
* Adopt a hand edit: the site records it as its own.
*
* It is only possible for a `grant` whose Steam id belongs to a website account,
* and the refusal says so — because the alternative is authoring privilege
* against a game account no person on this site holds, which is precisely the
* thing D28 decided not to do.
*/
async function adoptDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
if (row.kind !== 'grant' && row.kind !== 'member') {
return res.status(400).json({
message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.',
})
}
const holder = await holderOf(row.subject)
if (!holder) {
return res.status(409).json({
message:
'That Steam account is not linked to any account on this site, so there is nobody to author this against. Revoke it instead, or ask the player to link.',
})
}
if (row.kind === 'grant') {
await db.insertGrant({
userId: holder.userId,
permission: row.object,
scope: row.serverId,
source: 'adopted',
note: 'Adopted from a hand edit',
grantedBy: req.user ? req.user.id : null,
})
} else {
const group = await db.getGroup(row.object)
if (!group) return res.status(409).json({ message: 'That group is not authored on this site' })
await db.addGroupMember(row.object, holder.userId, req.user ? req.user.id : null)
}
// Already in the game, so it is already pushed — recorded as such rather
// than left for the next sync to "apply". Without this the row would be
// desired-but-not-pushed, which is a state the loop would happily write
// again and the game would report as already correct: harmless, and a lie in
// the one table that exists to say what this site put there.
await db.addPushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }])
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.adopt',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(204).end()
} catch (err) {
log.error('failed to adopt drift', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to adopt that change' })
}
}
/**
* Revoke a hand edit.
*
* Queued rather than sent: the server may be down, and an instruction that is
* dropped because a game host was restarting is exactly the behaviour a site
* claiming to be the author of record must not have. The next successful sync
* carries it and the queue row goes.
*/
async function revokeDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
await db.queueRevocation({
serverId: row.serverId,
kind: row.kind,
subject: row.subject,
object: row.object,
requestedBy: req.user ? req.user.id : null,
})
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.revoke',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(202).json({ queued: true })
} catch (err) {
log.error('failed to queue a revocation', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to queue that revocation' })
}
}
/** Run the loop's pass now, for one server or for all of them, and report what happened. */
async function syncNow(req, res) {
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
try {
if (serverId && !(await knownServer(serverId))) {
return res.status(404).json({ message: 'No such server' })
}
await db.markDirty(serverId || model.FLEET)
await permSync.tick({ force: serverId })
await core.activity.log({
req,
action: 'rust.perm.sync',
detail: { server: serverId || 'all' },
})
const state = await model.overview()
return res.json({ servers: state.servers, drift: state.drift })
} catch (err) {
log.error('a forced sync failed', { server: serverId, error: err.message })
return res.status(500).json({ message: 'Failed to run the sync' })
}
}
/** Every permission name any configured server has registered, with which ones know it. */
async function catalogue(req, res) {
try {
const rows = await db.listCatalogue()
res.json({ permissions: groupCatalogue(rows) })
} catch (err) {
log.error('failed to read the catalogue', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission catalogue' })
}
}
function groupCatalogue(rows) {
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
byPermission.get(row.permission).push(row.serverId)
}
return [...byPermission.entries()]
.map(([permission, serverIds]) => ({ permission, servers: serverIds }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* The user id a write is about, from either an id or a username.
*
* The form sends a name, because a form that made an operator type a numeric id
* would be a form nobody could use. The id form stays accepted because the
* client already holds one on the panel inside core's user page, and looking a
* name back up from it would be a round trip to answer a question it has
* already answered.
*/
async function resolveUser(body) {
if (body.userId) return Number(body.userId)
if (!body.username) return null
const user = await db.findUserByUsername(String(body.username).trim())
return user ? user.id : null
}
/** Whether a scope names a server row. A disabled server still counts — it exists. */
async function knownServer(id) {
const rows = await servers.listForAdmin()
return rows.some((row) => row.id === id)
}
/** The website account that holds a Steam id, or null. */
async function holderOf(steamId) {
const links = await db.listLinks()
return links.find((link) => link.steamId === steamId) || null
}
module.exports = {
overview,
putGroup,
deleteGroup,
addMember,
removeMember,
addGrant,
removeGrant,
adoptDrift,
revokeDrift,
syncNow,
catalogue,
}

View File

@@ -0,0 +1,184 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/permissions…`. It is a second router rather than more
// routes on `rust.router.js` because it is a second subject: that one configures
// the bridge, this one authors privilege inside somebody's game.
//
// **Every route is `requireRole('admin')`.** The admin tier's own gate admits
// editors and moderators, and a moderator being able to grant themselves
// `kits.admin` on six servers is the whole of R1's "a weak link is now a
// privilege-escalation path" arriving through the front door instead. The tier
// gate is not re-implemented; this is one gate on top of it, exactly as the
// server-configuration routes do it.
//
// There is no module-declared site permission to gate these more finely with —
// `MODULE_API.md` has no such member at 1.10.0 — so role is the whole of the
// available vocabulary, and `admin` is the honest choice within it.
const core = require('../../core')
const express = core.express
const permissions = require('./permissions.controller')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
const permissionsRouter = express.Router()
/** A permission or group name, as both mod frameworks store them. */
const NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/i
permissionsRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The whole permission model'
// #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.'
/* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
requireRole('admin'),
permissions.overview,
)
permissionsRouter.get(
'/catalogue',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Permission names the servers have registered'
// #swagger.description = 'What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.'
/* #swagger.responses[200] = { description: 'Every registered name, and which servers know it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */
requireRole('admin'),
permissions.catalogue,
)
permissionsRouter.put(
'/groups/:name',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a permission group'
// #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
requireRole('admin'),
param('name').matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'),
body('title').optional().isString().trim().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('permissions').optional().isArray({ max: 500 }),
body('permissions.*').isString().matches(NAME),
validate,
permissions.putGroup,
)
permissionsRouter.delete(
'/groups/:name',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Delete a permission group'
// #swagger.description = 'Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.'
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
validate,
permissions.deleteGroup,
)
permissionsRouter.post(
'/groups/:name/members',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put an account in a group'
// #swagger.description = 'Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.'
/* #swagger.responses[204] = { description: 'Added' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
// Either identifier: the screen sends a name, the panel inside core's own user
// page already holds an id.
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
validate,
permissions.addMember,
)
permissionsRouter.delete(
'/groups/:name/members/:userId',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Take an account out of a group'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such group, or that account is not in it' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
param('userId').isInt({ min: 1 }).toInt(),
validate,
permissions.removeMember,
)
permissionsRouter.post(
'/grants',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Grant one permission to one person'
// #swagger.description = 'A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.'
/* #swagger.responses[201] = { description: 'Granted' } */
/* #swagger.responses[200] = { description: 'They already held it; nothing changed' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
/* #swagger.responses[404] = { description: 'No account on this site has that name' } */
requireRole('admin'),
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
body('permission').isString().matches(NAME),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('note').optional().isString().isLength({ max: 255 }),
validate,
permissions.addGrant,
)
permissionsRouter.delete(
'/grants/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove a grant'
// #swagger.description = 'The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such grant' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.removeGrant,
)
permissionsRouter.post(
'/drift/:id/adopt',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Adopt a hand edit'
// #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.'
/* #swagger.responses[204] = { description: 'Adopted' } */
/* #swagger.responses[400] = { description: 'That kind of drift cannot be adopted' } */
/* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.adoptDrift,
)
permissionsRouter.post(
'/drift/:id/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Revoke a hand edit'
// #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[404] = { description: 'No such drift' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.revokeDrift,
)
permissionsRouter.post(
'/sync',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Push the permission set now'
// #swagger.description = 'Runs the reconciliation loops pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.'
/* #swagger.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
validate,
permissions.syncNow,
)
module.exports = permissionsRouter

View File

@@ -0,0 +1,132 @@
// ── Admin · Rust — the handlers ───────────────────────────────────────────
//
// The write side of the module. Three things every handler here owes:
//
// 1. **Never return the token.** Not in a response, not in an error, not in an
// activity-log detail. It is accepted, encrypted and forgotten.
// 2. **Record the change.** `core.activity.log` writes core's own admin audit
// row. These handlers edit the credential that reaches a game host; "who
// changed this" has no second place it is recorded.
// 3. **Answer rather than throw.** An unhandled rejection reaches core's error
// handler and gets core blamed for a fault in this module.
const core = require('../../core')
const db = require('../../model/servers/servers.db')
const servers = require('../../model/servers/servers.model')
const sidecar = require('../../sidecarClient')
const log = core.logger('admin')
async function listServers(req, res) {
try {
res.json({ servers: await servers.listForAdmin() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ message: 'Failed to read the server list' })
}
}
async function putServer(req, res) {
const { id } = req.params
const { name, sidecarBaseUrl, sidecarToken, protocol, enabled, sortOrder } = req.body
try {
const existing = await db.getServer(id)
// A NEW server with no token is a row that can never reach its sidecar, and
// the operator will read the resulting "unreachable" as a network problem.
// Refusing it up front costs one round trip and saves that hunt. An EXISTING
// row is a different case: omitting the token is how you say "leave it".
if (!existing && !sidecarToken) {
return res.status(400).json({ message: 'A new server needs its sidecar token' })
}
await db.upsertServer({
id,
name,
sidecarBaseUrl,
// `encryptToken` returns null for an empty value, and `upsertServer` reads
// null as "do not write this column". The two halves of that rule are in
// different files on purpose: the model decides what a blank means, the SQL
// decides what null does, and neither has to know the other's reason.
sidecarTokenEnc: servers.encryptToken(sidecarToken),
protocol: protocol === undefined ? sidecar.PROTOCOL_VERSION : protocol,
enabled: enabled === undefined ? true : enabled,
sortOrder: sortOrder === undefined ? 0 : sortOrder,
})
await core.activity.log({
req,
action: 'rust.server.save',
detail: {
server: id,
created: !existing,
sidecarBaseUrl,
// Whether the credential was rotated, never the credential.
tokenChanged: Boolean(sidecarToken),
},
})
return res.status(204).end()
} catch (err) {
log.error('failed to save a server', { server: id, error: err.message })
return res.status(500).json({ message: 'Failed to save the server' })
}
}
async function deleteServer(req, res) {
const { id } = req.params
try {
const existing = await db.getServer(id)
if (!existing) return res.status(404).json({ message: 'No such server' })
await db.deleteServer(id)
await core.activity.log({ req, action: 'rust.server.delete', detail: { server: id } })
return res.status(204).end()
} catch (err) {
log.error('failed to delete a server', { server: id, error: err.message })
return res.status(500).json({ message: 'Failed to delete the server' })
}
}
/**
* Probe one sidecar and report what came back.
*
* This is the route that tells a wrong URL from a wrong token from a mismatched
* protocol, and that distinction is the whole reason it exists: all three present
* to an operator as "the site says my server is offline", and each has a
* different fix. The status string from `sidecarClient` is carried through
* verbatim so the panel can say which.
*/
async function testServer(req, res) {
const { id } = req.params
try {
const row = await db.getServer(id)
if (!row) return res.status(404).json({ message: 'No such server' })
const result = await sidecar.health(servers.withToken(row))
await core.activity.log({
req,
action: 'rust.server.test',
detail: { server: id, ok: result.ok, status: result.status },
})
return res.json({
ok: result.ok,
status: result.status,
// `data` is the sidecar's own health document on success and the mismatch
// detail on a 409. Both are safe to show: neither carries a credential.
sidecar: result.data || null,
})
} catch (err) {
log.error('failed to probe a sidecar', { server: id, error: err.message })
return res.status(500).json({ message: 'Failed to probe the sidecar' })
}
}
module.exports = { listServers, putServer, deleteServer, testServer }

View File

@@ -0,0 +1,104 @@
// ── Admin · Rust ──────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/admin/rust`. The tier's gate is already applied: `admin`
// sits behind `noindex, isLoggedIn, requireRole('admin','editor','moderator')`.
//
// **That gate is broader than these routes should be.** Editing a server row
// means editing the credential that reaches a game host, which is an
// administrator's job and not a moderator's — so the routes that write add
// `requireRole('admin')` on top of the tier. A module adds per-route gates over
// the tier gate and never re-implements it; this is what adding one looks like.
//
// ── The token is write-only ───────────────────────────────────────────────
//
// `sidecarToken` is accepted and never returned. The list route reports
// `hasToken` instead, because a blank field otherwise means both "unset" and
// "set, and not being shown to you". An empty string on a save leaves the stored
// value alone — an operator renaming a server must not have to re-paste a
// credential, and a form that posts its own blank field would otherwise erase one
// on every unrelated edit.
const core = require('../../core')
const express = core.express
const admin = require('./rust.controller')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
const adminRustRouter = express.Router()
// R2's authoring surface, under `/rust/permissions`. Its own file because it is
// its own subject — this router configures the bridge, that one decides who may
// do what inside the game the bridge reaches.
adminRustRouter.use('/permissions', require('./permissions.router'))
// R18's editor, under `/rust/config`. A third subject again: this router
// configures the BRIDGE, `permissions` decides who may do what inside the game,
// and this one edits the game host's own plugin settings.
adminRustRouter.use('/config', require('./config.router'))
// Who may see who is online, under `/rust/visibility`. The org lead's rule is
// that nothing names who is online by default; this is where an operator
// deliberately widens it, fleet-wide or for one server.
adminRustRouter.use('/visibility', require('./visibility.router'))
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Every configured Rust server'
// #swagger.description = 'The operators server rows with their sidecar URLs, whether a token is stored, and whether each sidecar was reachable on the last poll. The token itself is never returned.'
/* #swagger.responses[200] = { description: 'The configured servers', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminServerList" } } } } */
admin.listServers,
)
adminRustRouter.put(
'/servers/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a Rust server'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body' } */
requireRole('admin'),
param('id')
.matches(/^[a-z0-9][a-z0-9-]{0,63}$/)
.withMessage('id must be lowercase letters, digits and hyphens'),
body('name').isString().trim().isLength({ min: 1, max: 120 }),
// A base URL is validated for SHAPE and not for reachability: an operator
// configures a sidecar before installing it about half the time, and refusing
// the row because nothing answers yet would make the obvious order of
// operations impossible.
body('sidecarBaseUrl').isURL({ require_tld: false, protocols: ['http', 'https'] }),
body('sidecarToken').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
body('protocol').optional().isInt({ min: 1, max: 1000 }).toInt(),
body('enabled').optional().isBoolean().toBoolean(),
body('sortOrder').optional().isInt({ min: -1000, max: 1000 }).toInt(),
validate,
admin.putServer,
)
adminRustRouter.delete(
'/servers/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove a Rust server'
// #swagger.description = 'Deletes the server row and the observed state that hangs off it. It does not touch the sidecar or the game host — those are removed with the installer.'
/* #swagger.responses[204] = { description: 'Deleted' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.deleteServer,
)
adminRustRouter.post(
'/servers/:id/test',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Probe a servers sidecar'
// #swagger.description = 'Calls the sidecars health endpoint with the stored credential and reports what came back — whether it answered, whether the bridge plugin is connected to it, and which protocol version it speaks. This is the one route that tells a wrong URL from a wrong token from a mismatched version.'
/* #swagger.responses[200] = { description: 'What the sidecar said', content: { "application/json": { schema: { $ref: "#/components/schemas/RustSidecarProbe" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.testServer,
)
module.exports = adminRustRouter

View File

@@ -0,0 +1,202 @@
// ── The `admin.users.detail` slot's handlers ──────────────────────────────
//
// What an operator can see and do about one website user's Rust identity. The
// user id is the PARENT's — `req.params.id` off core's `/admin/users/:id` — and
// every statement here is scoped by it, so a panel opened on one user cannot
// read or write another's rows by editing a path segment.
const core = require('../../core')
const links = require('../../model/links/links.model')
const permissionsDb = require('../../model/permissions/permissions.db')
const permissions = require('../../model/permissions/permissions.model')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin')
/**
* GET /admin/users/:id/rust/links
*
* The linked Steam accounts and, per server, what this module knows about the
* player behind them — all-time rather than this wipe's, because an operator
* looking at a user wants their history and the public leaderboard already
* answers the other question.
*
* **An empty array is an answer.** Most users have no Rust link at all, and the
* panel renders nothing rather than an error for them.
*/
async function listLinks(req, res) {
try {
res.json({ links: await links.forAdmin(req.params.id) })
} catch (err) {
log.error('failed to read a users Rust links', { error: err.message })
res.status(500).json({ message: 'Failed to read this users Rust accounts' })
}
}
/**
* DELETE /admin/users/:id/rust/links/:steamId — staff sever a link (D25).
*
* **This is the counterweight to D23.** The site refuses to move a Steam id that
* another website account already holds, and the player's own way out is
* `/unlink` in game — which is no way out at all for somebody who has lost access
* to that Steam account, or to the site account holding it. Staff are that route.
*
* Scoped by the parent user id in the statement rather than checked first: the
* ownership test and the deletion are one operation, and a link that belongs to a
* different user answers 404 from the page it was not on.
*/
async function removeLink(req, res) {
const { steamId } = req.params
const userId = req.params.id
try {
const removed = await links.unlinkOwned(steamId, userId)
if (!removed) return res.status(404).json({ message: 'That account is not linked to this user' })
// The one write this panel has, so it is the one thing here worth an audit
// row: after phase 7 a link is what permissions are granted against, and
// "who severed it" stops being a curiosity.
await core.activity.log({
req,
action: 'rust.account.unlink.staff',
detail: { steamId, userId: Number(userId) },
})
return res.json({ unlinked: true })
} catch (err) {
log.error('failed to unlink a Steam account', { error: err.message })
return res.status(500).json({ message: 'Failed to unlink that account' })
}
}
/**
* GET /admin/users/:id/rust/permissions
*
* What this person may do in game, and — the part that is easy to leave out —
* whether any of it reaches anybody. A grant against an account with no linked
* Steam id is authored, stored, pushed nowhere and looks identical to a working
* one on every screen that does not say so.
*/
async function listPermissions(req, res) {
const userId = Number(req.params.id)
try {
const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([
permissionsDb.listGroups(),
permissionsDb.listGroupPermissions(),
permissionsDb.listGroupMembers(),
permissionsDb.listGrants({ userId }),
permissionsDb.listLinks(),
])
const theirs = new Set(
members.filter((row) => row.userId === userId).map((row) => row.groupName),
)
const carried = new Map()
for (const row of groupPermissions) {
if (!carried.has(row.groupName)) carried.set(row.groupName, [])
carried.get(row.groupName).push(row.permission)
}
res.json({
groups: groups
.filter((group) => theirs.has(group.name))
.map((group) => ({
name: group.name,
title: group.title,
scope: group.scope,
permissions: carried.get(group.name) || [],
})),
grants: permissions.collapseGrants(grants).map((grant) => ({
id: grant.id,
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note,
grantedAt: grant.grantedAt,
})),
reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId),
})
} catch (err) {
log.error('failed to read a users Rust permissions', { error: err.message })
res.status(500).json({ message: 'Failed to read this users Rust permissions' })
}
}
/** POST /admin/users/:id/rust/permissions/grants */
async function addGrant(req, res) {
const userId = Number(req.params.id)
const permission = permissions.normaliseName(req.body.permission)
const scope = String(req.body.scope || permissions.FLEET)
try {
if (scope !== permissions.FLEET) {
const known = await servers.listForAdmin()
if (!known.some((row) => row.id === scope)) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
}
const { inserted } = await permissionsDb.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await permissionsDb.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { userId, permission, scope },
})
}
return res.status(inserted ? 201 : 200).json({ granted: inserted })
} catch (err) {
log.error('failed to grant a permission', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
/**
* DELETE /admin/users/:id/rust/permissions/grants/:grantId
*
* **Scoped by the user as well as by the grant**, like every other write in this
* panel: a grant id belonging to somebody else answers `404` rather than
* removing a privilege from a person whose page nobody was looking at.
*/
async function removeGrant(req, res) {
const userId = Number(req.params.id)
const grantId = Number(req.params.grantId)
try {
const grant = await permissionsDb.getGrant(grantId)
if (!grant || grant.userId !== userId) {
return res.status(404).json({ message: 'That grant does not belong to this user' })
}
await permissionsDb.deleteGrant(grantId)
await permissionsDb.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a grant', { userId, grant: grantId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that permission' })
}
}
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }

View File

@@ -0,0 +1,131 @@
// ── The `admin.users.detail` extension slot ───────────────────────────────
//
// R13's first slot, and the phase criterion in one file: *an operator sees the
// Steam id inside core's own user page*.
//
// MODULE_API.md §2.4's fourth mount shape — module routes hanging off a CORE
// resource. `/admin/users/:id` is a URL core owns and this module has something
// to say about it, so the routes cannot move behind a `/rust` prefix and cannot
// be registered anywhere else either. Core declares the slot; a module fills it,
// and only one module may.
//
// Three things about this router that are not true of the other three:
//
// • **`mergeParams: true`**, because the user id belongs to the parent. Without
// it `req.params.id` is undefined and every statement here silently scopes to
// nothing.
// • **The paths keep the module's own segment** (`/rust/links`, not `/links`).
// Core owns the resource and other modules may fill their own slots on other
// resources; a bare `/links` would be this module claiming a word on a URL it
// does not own.
// • **The gate is stricter than the admin tier's.** Core's users router is
// `requireRole('admin')` and the slot is mounted inside it, so editors and
// moderators never reach here — which is right for a surface that can sever
// what phases 7 and 13 grant against.
//
// The client half is registered under the SAME name (`registry.registerExtension`
// in `entry.jsx`) and builds its own client for these two routes; a slot passes a
// component `userId` and nothing else.
const core = require('../../core')
const express = core.express
const { body, param } = core.validator
const usersRust = require('./usersRust.controller')
const { validate } = core.middleware
// Same bound the player tier states, for the same reason: nothing but digits
// reaches a `WHERE steam_id = ?`.
const STEAM_ID_RE = /^[0-9]{5,32}$/
const usersRustRouter = express.Router({ mergeParams: true })
usersRustRouter.get(
'/rust/links',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users linked Steam accounts and their Rust record (admin only)'
// #swagger.description = 'Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminLinkList" } } } } */
param('id').isInt(),
validate,
usersRust.listLinks,
)
usersRustRouter.delete(
'/rust/links/:steamId',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Sever a users Steam link (admin only)'
// #swagger.description = 'Staff release a link on this users behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #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 this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
param('steamId').matches(STEAM_ID_RE),
validate,
usersRust.removeLink,
)
// ── Phase 7: what this person may do in game ─────────────────────────────
//
// The same panel, one section lower. It is here rather than only on the
// permissions screen because the question an operator actually has is about a
// PERSON — "why can this player spawn a kit" is asked on their page, not on a
// list of groups — and because the slot is already the place this module says
// everything else it knows about one user.
//
// Both writes go through the ordinary authored tables and the ordinary loop. A
// grant made here reaches the game when the mirror next reconciles, which is
// seconds, and never inside this request.
usersRustRouter.get(
'/rust/permissions',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users Rust privileges (admin only)'
// #swagger.description = 'The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Their groups and grants', content: { "application/json": { schema: { $ref: "#/components/schemas/RustUserPermissions" } } } } */
param('id').isInt(),
validate,
usersRust.listPermissions,
)
usersRustRouter.post(
'/rust/permissions/grants',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Grant a Rust permission to this user (admin only)'
// #swagger.description = 'Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirrors next pass.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[201] = { description: 'Granted' } */
/* #swagger.responses[200] = { description: 'They already held it' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('permission').isString().matches(/^[a-z0-9][a-z0-9._-]{0,127}$/i),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
validate,
usersRust.addGrant,
)
usersRustRouter.delete(
'/rust/permissions/grants/:grantId',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Remove a Rust permission from this user (admin only)'
// #swagger.description = 'Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody elses privilege. The revoke reaches the game on the mirrors next pass.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['grantId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grant to remove.' }
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such grant for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
param('grantId').isInt({ min: 1 }).toInt(),
validate,
usersRust.removeGrant,
)
module.exports = usersRustRouter

View File

@@ -0,0 +1,51 @@
// ── Admin · Rust · Visibility — the handlers ──────────────────────────────
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('visibility')
/**
* The page's whole state: both settings, and each server's clan board beside
* the roster setting. The board is where "this server runs the uMod Clans
* plugin, whose clans are not Teams" (D47) and "this server is at the game's
* 100-clan ceiling" (D55) come from.
*/
async function describe() {
const [settings, boards] = await Promise.all([visibility.describe(), clans.boardsForAdmin()])
return { ...settings, clans: { ...settings.clans, servers: boards } }
}
async function read(req, res) {
try {
res.json(await describe())
} catch (err) {
log.error('failed to read visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to read the visibility settings' })
}
}
async function update(req, res) {
try {
const { fleet, servers, clanRoster } = req.body || {}
const result = await visibility.update({ fleet, servers, clanRoster }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
}
// One row per save, naming everything it changed. Widening who may see the
// roll call is exactly the kind of change somebody later needs to trace to a
// person and a time.
await core.activity.log({ req, action: 'rust.visibility.save', detail: result.changed })
res.json(await describe())
} catch (err) {
log.error('failed to save visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to save the visibility settings' })
}
}
module.exports = { read, update }

View File

@@ -0,0 +1,53 @@
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/visibility`. Who may see what the servers say about the
// people on them — a fourth subject beside the bridge, the permissions and the
// mod configuration.
//
// **Every route is `requireRole('admin')`.** The tier's own gate admits editors
// and moderators, and a moderator widening the roll call to the public is the
// decision the org lead settled should be deliberate. Reading is gated the same
// as writing: the screen is one form, and a view of the settings without the
// power to change them is not something anybody has asked for.
const core = require('../../core')
const express = core.express
const visibility = require('./visibility.controller')
const { requireRole, validate } = core.middleware
const { body } = core.validator
const visibilityRouter = express.Router()
const AUDIENCES = ['staff', 'signed_in', 'public']
const CLAN_AUDIENCES = ['members', 'signed_in', 'public']
visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
// #swagger.description = 'The presence fleet default and every servers optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboards `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clans own linked members, and staff) and each servers clan board — whether it is current, at the games 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
)
visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online, or who may see a clan roster'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, or any of them together. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
/* #swagger.responses[404] = { description: 'A server that does not exist' } */
requireRole('admin'),
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
validate,
visibility.update,
)
module.exports = visibilityRouter

View File

@@ -0,0 +1,171 @@
// ── Player · Rust — the handlers ──────────────────────────────────────────
//
// Two things live here now: the server list as a signed-in caller sees it (phase
// 1's honest placeholder, which must not reshape the list — it calls the same
// model the public tier does so the two cannot drift), and R1's identity link.
//
// ── Every refusal is a sentence, and they are not interchangeable ─────────
//
// The link handler's whole job is turning a discriminated result into the right
// thing to tell a player, and the four wrong answers are wrong in different ways:
//
// • "that code is unknown or expired" → run `/link` again
// • "another account holds that Steam id" → run `/unlink` in game, or ask staff
// • "we could not reach a server" → try again in a minute; the code is fine
// • "no servers are configured" → nothing the player can do at all
//
// A player told to run `/link` again when the server their code came from was
// merely unreachable will run it again, get another code from the same
// unreachable server, and be told the same thing. That is the failure the
// `unsure` branch exists to prevent.
const core = require('../../core')
const links = require('../../model/links/links.model')
const permissions = require('../../model/permissions/permissions.model')
const servers = require('../../model/servers/servers.model')
const log = core.logger('player')
async function listServers(req, res) {
try {
res.json({ servers: await servers.listPublic() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ message: 'Failed to read the server list' })
}
}
/** GET /player/rust/links — the Steam accounts the caller holds. */
async function listLinks(req, res) {
try {
res.json({ links: await links.listForUser(req.user.id) })
} catch (err) {
log.error('failed to read a players links', { error: err.message })
res.status(500).json({ message: 'Failed to read your linked accounts' })
}
}
/**
* POST /player/rust/link — redeem a code from `/link` in game.
*
* The fleet loop is the model's (D24); this maps its answer onto a status and a
* sentence. **A refused code is a 400 and an unreachable server is a 503**,
* because a client that cannot tell them apart cannot tell a player whether to
* try again or to go and get a new code.
*/
async function confirmLink(req, res) {
const code = String(req.body.code || '').trim()
try {
const result = await links.redeem({ code, userId: req.user.id })
if (result.ok) {
// Logged on the player tier too, not only for admin writes: this is the
// moment a website account starts being able to hold permissions and
// entitlements in a game, and "when did this account become that Steam id"
// is a question an operator will eventually need answered.
await core.activity.log({
req,
action: 'rust.account.link',
detail: { steamId: result.link.steamId, serverId: result.link.serverId },
})
return res.json({ linked: true, link: result.link, already: Boolean(result.already) })
}
switch (result.reason) {
case 'taken':
// Naming the holder is deliberate and it is not a leak: the player is
// signed in, the account named is one they may well own, and without the
// name the advice ("sign in as that account, or ask staff") is unusable.
return res.status(409).json({
message: result.username
? `That Steam account is already linked to ${result.username}. Run /unlink in game to release it.`
: 'That Steam account is already linked to another website account. Run /unlink in game to release it.',
})
case 'unsure':
return res.status(503).json({
message:
'One of the servers could not be reached, so that code could not be checked. ' +
'Your code is still good — try again in a minute.',
})
case 'offline':
return res.status(503).json({
message: 'The game servers are unreachable right now — try again in a minute.',
})
case 'no-servers':
return res.status(503).json({ message: 'No Rust servers are configured on this site yet.' })
default:
return res.status(400).json({
message: 'That code is unknown or has expired. Type /link in game for a new one.',
})
}
} catch (err) {
log.error('failed to confirm a link code', { error: err.message })
return res.status(500).json({ message: 'Failed to confirm that code' })
}
}
/**
* DELETE /player/rust/links/:steamId — release a link the caller holds.
*
* Scoped to the caller inside the statement, so "not linked" and "not yours"
* answer the same 404 — a signed-in stranger must not be able to discover which
* Steam ids are linked by deleting them one at a time.
*/
async function removeLink(req, res) {
const { steamId } = req.params
try {
const removed = await links.unlinkOwned(steamId, req.user.id)
if (!removed) return res.status(404).json({ message: 'That account is not linked to you' })
await core.activity.log({ req, action: 'rust.account.unlink', detail: { steamId } })
return res.json({ unlinked: true })
} catch (err) {
log.error('failed to unlink', { error: err.message })
return res.status(500).json({ message: 'Failed to unlink that account' })
}
}
/**
* GET /player/rust/permissions — what the site has given this player in game.
*
* Phase 7 made the website the author of in-game privilege and gave an operator
* every view of it; this is the other side of that, and it is the first time a
* player can see what they hold without asking one. Read-only by construction:
* nothing a player can do here changes a grant, because a grant they could
* change would not be a grant.
*
* The caller's Steam ids come from the link model rather than the permission
* one, so the two questions stay in the files that own them — and the pushed
* ledger is keyed by Steam id, which is the whole reason this read needs them.
*/
async function listPermissions(req, res) {
try {
const [accounts, serverRows] = await Promise.all([
links.listForUser(req.user.id),
servers.listPublic(),
])
const held = await permissions.forPlayer(
req.user.id,
accounts.map((account) => account.steamId),
serverRows,
)
res.json({ ...held, accounts: accounts.length })
} catch (err) {
log.error('failed to read a players entitlements', { error: err.message })
res.status(500).json({ message: 'Failed to read what you hold in game' })
}
}
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions }

View File

@@ -0,0 +1,122 @@
// ── 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.get(
'/permissions',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'What the site has given the caller in game'
// #swagger.description = 'The groups and direct grants the site holds for the signed-in user, each resolved to the servers its scope reaches and marked with whether that server has it yet. Read-only: a grant a player could change would not be a grant. `live` is the pushed ledger rather than the authored row, so an entitlement that has not reached a game reads as waiting — which is also what an offline server, a permission no loaded plugin registered, and an account the store has never seen all look like from here.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'What the caller holds', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPlayerPermissions" } } } } */
rust.listPermissions,
)
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

View File

@@ -0,0 +1,219 @@
// ── Public · Rust — the handlers ──────────────────────────────────────────
//
// Thin on purpose: read the request, call a model, answer. Everything worth
// testing is in the model, which needs no express and no database to test.
//
// **A handler must not throw past express.** Core mounts this router inside its
// own tier router, so an unhandled rejection here reaches core's error handler
// and answers 500 — survivable, but it means an operator sees core blamed for a
// fault in this module. Catch, log through `core.logger` (so the line carries the
// module id), and answer something honest.
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const events = require('../../model/events/events.model')
const servers = require('../../model/servers/servers.model')
const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('public')
/**
* Marks a response as depending on who asked.
*
* Three routes below answer differently for a moderator and for a stranger, and
* a shared cache in front of the site that stored the moderator's answer would
* hand the roll call to the next anonymous visitor. `private` keeps it out of
* every cache but the viewer's own; `Vary` says why, for any cache that reads it.
*/
function perViewer(res) {
res.set('Cache-Control', 'private, no-store')
res.vary('Cookie')
res.vary('Authorization')
}
async function listServers(req, res) {
try {
res.json({ servers: await servers.listPublic() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ message: 'Failed to read the server list' })
}
}
/**
* One server, or a 404.
*
* **The 404 is the feature.** Everything else under `/servers/:id` answers an
* empty list for a server that does not exist — an unknown id has no events, no
* leaderboard and nobody online, and each of those is a perfectly good answer to
* the question it was asked. Only this route can tell the page that the server
* itself is not there, which is what stops `/rust/servers/typo` rendering as a
* quiet server with nothing to say.
*/
async function getServer(req, res) {
try {
const server = await servers.getPublic(req.params.id)
if (!server) {
res.status(404).json({ message: 'No such server' })
return
}
res.json({ server })
} catch (err) {
log.error('failed to read a server', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the server' })
}
}
/**
* The killfeed, and everything else public that happened on one server.
*
* **`admin` is not passed, and that is the whole security posture of this
* handler.** `events.recent` takes the viewer explicitly and defaults to the
* public allowlist, so the way to leak an IP address from here is to add an
* argument rather than to forget one.
*
* `presence` is resolved per request from the operator's setting. Below it, the
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
* so with `presenceHidden`, so a page can explain a quiet feed instead of
* implying a quiet server.
*/
async function listEvents(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
perViewer(res)
res.json({
events: await events.recent({
serverId: req.params.id,
presence: presence.visible,
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,
}),
presenceHidden: !presence.visible,
presenceAudience: presence.required,
})
} catch (err) {
log.error('failed to read events', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read events' })
}
}
async function listLeaderboard(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
perViewer(res)
res.json({
leaderboard: await events.leaderboard({
serverId: req.params.id,
wipeId: req.query.wipe || null,
sort: req.query.sort,
limit: req.query.limit,
presence: presence.visible,
}),
})
} catch (err) {
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the leaderboard' })
}
}
async function listWipes(req, res) {
try {
res.json({ wipes: await events.wipes(req.params.id) })
} catch (err) {
log.error('failed to read wipes', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read wipes' })
}
}
/**
* Who is on the server right now — or, below the operator's audience, how many.
*
* The count stays public: it is already on the server list and in the footer,
* and a number names nobody. The names do not, by default (the org lead's rule,
* `model/visibility`). A hidden answer is still a 200 with the same shape — an
* empty `players` array — plus `hidden` and `count`, so a client that predates
* the flag renders an empty list rather than breaking, and a current one can say
* "12 online" instead of "nobody".
*/
async function listOnline(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
perViewer(res)
if (!presence.visible) {
const server = await servers.getPublic(req.params.id)
res.json({
players: [],
hidden: true,
count: server ? server.players : 0,
audience: presence.required,
})
return
}
const players = await events.online(req.params.id)
res.json({ players, hidden: false, count: players.length, audience: presence.required })
} catch (err) {
log.error('failed to read presence', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read who is online' })
}
}
/**
* Who is asking, as core describes a viewer to `projectRoster`: `{ userId, role }`
* or null. Only the id is trusted — `model/clans` re-reads the row — so a token
* that cannot be decoded is simply nobody.
*/
function viewerOf(req) {
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return null
return { userId: claimed.id, role: claimed.role || null }
} catch (err) {
return null
}
}
/**
* One server's clans (D58): name, colour, score and member count, best first.
*
* Public at every setting, because none of it names a player. `board` says
* whether the list can be trusted — a server whose plugin predates protocol 6,
* or whose clans the bridge cannot read, answers an empty list AND the reason,
* so the tab can say "unavailable" rather than "no clans".
*/
async function listClans(req, res) {
try {
res.json(await clans.listForServer(req.params.id))
} catch (err) {
log.error('failed to read clans', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read clans' })
}
}
/**
* One clan, and its roster when the viewer is inside the roster audience (D48).
*
* The same decision core's `projectRoster` makes, from the same function, so
* this page and core's roster cannot disagree about who may look. Below the
* audience the clan is still described — its name and its count are public —
* and `roster.visible` is false with no names at all.
*/
async function getClan(req, res) {
try {
const answer = await clans.getForViewer(req.params.externalId, viewerOf(req))
perViewer(res)
if (!answer) {
res.status(404).json({ message: 'No such clan' })
return
}
res.json(answer)
} catch (err) {
log.error('failed to read a clan', { clan: req.params.externalId, error: err.message })
res.status(500).json({ message: 'Failed to read the clan' })
}
}
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline, listClans, getClan }

View File

@@ -0,0 +1,145 @@
// ── Public · Rust ─────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/public/rust` by `index.js`. One express Router, built from
// CORE's express (`core.express`) — never from a `require('express')` of your
// own, which would not resolve from here anyway (MODULE_API.md §7.2).
//
// **The tier's gate is already on.** This router sits inside core's public tier,
// which is behind nothing by design. Per-route middleware goes on top, and
// `siteMode` is the one worth understanding: it is what makes a route respect the
// operator's maintenance switch. Core applies it to its own content routes and
// deliberately does not apply it to its status endpoints, because status is
// exactly what an operator wants visible *during* maintenance.
//
// The server list is content, not status — it is the module's landing page — so
// it takes `siteMode`.
//
// ── About the `#swagger` comments ─────────────────────────────────────────
//
// They are not documentation *of* the code; they are the source the OpenAPI
// fragment is generated from (`npm run swagger`, §2.8). swagger-autogen reads
// them as JavaScript literals it evaluates, so a QUOTE CHARACTER inside a
// single-quoted description ends the string early — and the failure is silent:
// the value is truncated at that character while the generator prints success.
// Use a typographic apostrophe () in prose. A backtick is fine.
const core = require('../../core')
const express = core.express
const servers = require('./rust.controller')
const { siteMode } = core.middleware
const rustRouter = express.Router()
rustRouter.get(
'/servers',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Every Rust server this site follows'
// #swagger.description = 'The operators configured Rust servers and what each one last reported. Answers with `online: false` and `stale: true` rather than failing when a game server or its sidecar is unreachable — the sites availability does not depend on the games.'
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
siteMode,
servers.listServers,
)
// ── One server's read path ────────────────────────────────────────────────
//
// Every route below is public, and every one of them answers from this module's
// own tables — never from a live call to a sidecar. That is what lets the
// killfeed and the leaderboard render while every game server in the fleet is
// off, which is the same promise the server list makes.
//
// **The events route serves an ALLOWLIST, default-deny** (`catalogue.js`).
// Protocol 2 carries IP addresses and player reports; they are stored, and they
// do not come out here.
rustRouter.get(
'/servers/:id',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust server'
// #swagger.description = 'The same shape the list answers with, for one server, and a `404` when there is no such server or an operator has disabled it. The detail page needs the difference: every other route under this path answers an empty list for an id that does not exist, because an unknown server genuinely has no events and nobody online.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The server' } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
siteMode,
servers.getServer,
)
rustRouter.get(
'/servers/:id/events',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Recent events on one Rust server'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operators presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Recent events, newest first' } */
siteMode,
servers.listEvents,
)
rustRouter.get(
'/servers/:id/leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The leaderboard for one Rust server'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a players history without ending it. `lastSeen` is withheld below the operators presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'The leaderboard' } */
siteMode,
servers.listLeaderboard,
)
rustRouter.get(
'/servers/:id/wipes',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Every wipe this server has had'
// #swagger.description = 'Newest first. A wipe id is derived by the bridge plugin from the saves creation time and stamped on every frame, so it is the same id the events and the leaderboard are filtered by.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The wipes' } */
siteMode,
servers.listWipes,
)
rustRouter.get(
'/servers/:id/online',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Who is on one Rust server right now'
// #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one. **Nothing names who is online by default**: below the operators presence audience (staff unless widened) the names are withheld and only `count` is answered.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'Who is online — or, below the operators presence audience, only how many', content: { "application/json": { schema: { $ref: "#/components/schemas/RustOnline" } } } } */
siteMode,
servers.listOnline,
)
// ── Clans (phase 9) ───────────────────────────────────────────────────────
//
// Rust's own clans, which this module also answers core's Team questions from.
// The list is public (D58); a roster is not (D48).
rustRouter.get(
'/servers/:id/clans',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The clans on one Rust server'
// #swagger.description = 'Every clan on the servers clan board, best score first: name, colour, score and member count. Public, because none of it names a player. `board` says whether the list is current and complete — a server whose bridge cannot read its clans answers an empty list and the reason, and a server at the games 100-clan ceiling says `truncated`.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The clans', content: { "application/json": { schema: { $ref: "#/components/schemas/RustClanList" } } } } */
siteMode,
servers.listClans,
)
rustRouter.get(
'/clans/:externalId',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust clan'
// #swagger.description = 'A clan and, when the viewer is inside the operators clan roster audience, its roster. The audience defaults to the clans own members (a website account linked to one of them) and staff. Below it the clan is still described and `roster.visible` is false with no names. The roster never carries a Steam id or a website account id. `externalId` is `<server>:<clan>:<created>`, the same identity the sites Team pages use.'
// #swagger.parameters['externalId'] = { in: 'path', required: true, description: 'The clans identity: server, clan id and creation time in epoch ms, joined by colons', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The clan', content: { "application/json": { schema: { $ref: "#/components/schemas/RustClan" } } } } */
/* #swagger.responses[404] = { description: 'No such clan' } */
siteMode,
servers.getClan,
)
module.exports = rustRouter

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env node
// ── Does the release actually ship everything the module needs? ────────────
//
// `ci/bundle.json` says what a release copies. `server/index.js` says what the
// module requires. Nothing keeps two lists in agreement on its own, and the first
// module this project shipped proved it: `Module-uo` added `server/commands/` in a
// cutover, its include list did not learn about it, and v1.0.0 installed cleanly
// and then died on the operator's box with
//
// module "uo" failed to load — {"stage":"register","reason":"Cannot find
// module './commands/guild.command'"}
//
// Nothing caught it, because the PR checks install the module by copying the
// WHOLE repo into core — they only ever exercised a tree that had the file. **The
// subset only exists in the release**, and the release had no check that the
// subset was complete. This module has that check from its first release rather
// than after its first outage.
//
// It asks the question in the two places it can be asked:
//
// --check (PR checks) Every file reachable from the entry point by a
// relative require lives under something ci/bundle.json
// lists. Source-tree only, so it is fast and needs no
// assembled bundle — it fails on the PR that adds the
// directory, which is where the fix is cheapest.
//
// --bundle <dir> (release) Every relative specifier inside an ASSEMBLED bundle
// resolves to a file that is in it. Asked of the
// artifact rather than of the source, so it also
// catches a copy that half-failed, a list that names a
// path that has moved, and anything else between the
// declaration and the tarball.
//
// The two are deliberately not the same question. The first is about the list
// being right; the second is about the tarball being right. A release runs both.
//
// ── The third question, which is this module's own ─────────────────────────
//
// `server/package.json` declares **no runtime dependencies**, and the release
// therefore runs no `npm ci` and packs no `node_modules`. That is a decision, not
// an accident (org lead, phase 2), and the whole value of it is that the day it
// stops being true is a loud day. So both modes also assert the declaration is
// still empty: add a `dependencies` entry without teaching release.yml to install
// and pack it, and the bundle ships an import of something that is not there —
// the missing-directory failure again, wearing a different hat.
//
// ── Why reachability, and not "require the entry point" ────────────────────
//
// The obvious check — require the bundle's entry and see if it throws — does not
// work here, and the reason is in index.js's own header: its requires are inside
// `register()` because require order is load-bearing (`core.init(ctx)` has to run
// before anything under `router/` is required). So requiring the entry evaluates
// exactly one line, `require('./core')`, and reports success on a bundle missing
// every router it has. Calling `register()` for real would need a fake `ctx`
// complete enough to satisfy the whole module — which is what `test/` is for, and
// `test/` does not ship. Walking the requires statically asks the same question
// without needing either.
const fs = require('fs')
const path = require('path')
const { stripCommentsAndTemplates } = require('./checkImports')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
// Only relative specifiers. A bare one is checkImports.js's question, not this
// one, and the two failures want different advice.
const RELATIVE = /(?:require\(|from\s+|import\()\s*['"](\.[^'"]+)['"]/g
/**
* Resolve a relative specifier the way Node would, for the file cases that can
* appear here: an exact path, `+.js`/`+.json`, or a directory's `index.js`.
*
* Returns null when nothing exists — which is the finding, not an error.
*/
function resolveFile(fromDir, specifier) {
const base = path.resolve(fromDir, specifier)
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]
for (const c of candidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
}
return null
}
/**
* Every file reachable from `entry` by following relative requires, plus every
* specifier that resolved to nothing.
*
* Exported so the test can point it at fixtures — the same reason checkImports.js
* exports `scan`. A check that has never been shown to fail is a check nobody
* knows the state of, and this one is load-bearing for every release.
*/
function reachable(entry) {
const seen = new Set()
const missing = []
const queue = [entry]
while (queue.length) {
const file = queue.shift()
if (seen.has(file)) continue
seen.add(file)
// A .json dependency is a leaf: it is reached, it ships, and it has no
// requires of its own to follow.
if (file.endsWith('.json')) continue
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(RELATIVE)) {
const target = resolveFile(path.dirname(file), specifier)
if (target) queue.push(target)
else missing.push({ file, specifier })
}
}
return { files: [...seen], missing }
}
/**
* Everything ci/bundle.json says ends up in the bundle, as absolute paths:
* `server[]` relative to server/, `root[]` and `generated[]` relative to the
* module root. All three are equally "in the tarball" as far as a require is
* concerned — the only difference is how they get there.
*/
function declaredServerPaths(moduleRoot = MODULE_ROOT) {
const manifest = JSON.parse(fs.readFileSync(path.join(moduleRoot, 'ci', 'bundle.json'), 'utf8'))
return [
...manifest.server.map((p) => path.join(moduleRoot, 'server', p)),
...(manifest.root || []).map((p) => path.join(moduleRoot, p)),
...(manifest.generated || []).map((p) => path.join(moduleRoot, p)),
]
}
/**
* The runtime dependencies the shipped half declares.
*
* Empty is the shape this module is built around, and the release packs no
* `node_modules` because of it. Returned rather than asserted so both modes can
* report it with their own advice.
*/
function runtimeDependencies(moduleRoot = MODULE_ROOT) {
const pkgPath = path.join(moduleRoot, 'server', 'package.json')
if (!fs.existsSync(pkgPath)) return []
return Object.keys(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).dependencies || {})
}
const DEPENDENCY_ADVICE =
'The release packs no node_modules, because this module declared none. A dependency\n' +
'listed here but not installed and copied by .gitea/workflows/release.yml ships as an\n' +
'import of something that is not in the tarball — the module installs and then dies at\n' +
'the register stage on the operator\'s box.\n\n' +
'Either drop the dependency (everything the shipped half needs arrives on ctx — §2.3),\n' +
'or add the `npm ci --omit=dev` + copy steps to release.yml and list "node_modules" in\n' +
'ci/bundle.json\'s server[], then update this check.\n'
const covers = (declared, file) => declared.some((d) => file === d || file.startsWith(d + path.sep))
/**
* --check: is ci/bundle.json's list sufficient for what the entry point reaches?
*
* Reports the top-level entry to ADD rather than the individual files, because
* that is the edit: the list is stated in top-level paths, and a new directory
* arrives with a dozen files in it.
*/
function checkDeclaration(moduleRoot = MODULE_ROOT) {
const serverRoot = path.join(moduleRoot, 'server')
const entry = path.join(serverRoot, 'index.js')
const { files, missing } = reachable(entry)
const declared = declaredServerPaths(moduleRoot)
// Grouped by the entry that would have to be added, which is the top-level
// path under server/ — or, for the rare reachable file outside it, the path
// itself, since that one belongs in root[] instead.
const uncovered = new Map()
for (const file of files) {
if (covers(declared, file)) continue
const inServer = file.startsWith(serverRoot + path.sep)
const key = inServer
? `server/${path.relative(serverRoot, file).split(path.sep)[0]}`
: path.relative(moduleRoot, file).split(path.sep).join('/')
if (!uncovered.has(key)) uncovered.set(key, [])
uncovered.get(key).push(file)
}
return { uncovered, missing, reached: files.length, dependencies: runtimeDependencies(moduleRoot) }
}
/**
* --bundle: does every relative specifier inside an assembled bundle resolve?
*
* Walks the bundle's own server tree rather than starting from the entry point,
* so a file that ships but is broken is caught too.
*/
function checkBundle(bundleRoot) {
const serverRoot = path.join(bundleRoot, 'server')
const missing = []
const files = []
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name)
if (e.isDirectory()) {
// An installed dependency tree would be npm's business, not this
// check's. This module ships none; the skip stays so that the day one
// arrives, this is not also the thing that breaks.
if (e.name !== 'node_modules') walk(p)
} else if (/\.(js|mjs|cjs)$/.test(e.name)) {
files.push(p)
}
}
}
walk(serverRoot)
for (const file of files) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(RELATIVE)) {
if (!resolveFile(path.dirname(file), specifier)) missing.push({ file, specifier })
}
}
return { missing, scanned: files.length, dependencies: runtimeDependencies(bundleRoot) }
}
module.exports = {
reachable,
resolveFile,
checkDeclaration,
checkBundle,
declaredServerPaths,
runtimeDependencies,
}
// Required by a test, or run as the check? Only the second one exits.
if (require.main !== module) return
const bundleFlag = process.argv.indexOf('--bundle')
if (bundleFlag !== -1) {
const root = process.argv[bundleFlag + 1]
if (!root) {
console.error('--bundle needs the path to an assembled bundle')
process.exit(2)
}
const { missing, scanned, dependencies } = checkBundle(path.resolve(root))
if (missing.length) {
console.error(`\nThe assembled bundle is incomplete — ${missing.length} require(s) resolve to nothing:\n`)
for (const m of missing) {
console.error(` ${path.relative(root, m.file)}\n requires "${m.specifier}" — not in the bundle`)
}
console.error('\nAdd the missing path to ci/bundle.json.\n')
process.exit(1)
}
if (dependencies.length) {
console.error(`\nThe assembled bundle declares ${dependencies.length} runtime dependency(ies) it does not carry:\n`)
for (const d of dependencies) console.error(` ${d}`)
console.error(`\n${DEPENDENCY_ADVICE}`)
process.exit(1)
}
console.log(`OK — every relative require in the bundle resolves (${scanned} files scanned), and it needs no node_modules.`)
} else {
const { uncovered, missing, reached, dependencies } = checkDeclaration()
if (missing.length) {
console.error(`\n${missing.length} require(s) resolve to nothing in the source tree:\n`)
for (const m of missing) {
console.error(` ${path.relative(MODULE_ROOT, m.file)}\n requires "${m.specifier}"`)
}
console.error('')
process.exit(1)
}
if (uncovered.size) {
console.error('\nci/bundle.json does not ship everything server/index.js reaches.\n')
console.error('A release built from this list would install and then fail at the')
console.error("register stage with \"Cannot find module\", on the operator's box.\n")
for (const [key, files] of uncovered) {
console.error(` ${key} (${files.length} file${files.length === 1 ? '' : 's'} reachable)`)
for (const f of files.slice(0, 5)) console.error(` ${path.relative(MODULE_ROOT, f)}`)
if (files.length > 5) console.error(` … and ${files.length - 5} more`)
}
// server[] is written relative to server/, so name the entry to add rather
// than the path just displayed — they differ by exactly that prefix.
const toServer = [...uncovered.keys()].filter((k) => k.startsWith('server/'))
const toRoot = [...uncovered.keys()].filter((k) => !k.startsWith('server/'))
if (toServer.length) {
console.error(`\nAdd ${toServer.map((k) => `"${k.slice('server/'.length)}"`).join(', ')} to ci/bundle.json's server[].`)
}
if (toRoot.length) {
console.error(`\nAdd ${toRoot.map((k) => `"${k}"`).join(', ')} to ci/bundle.json's root[].`)
}
console.error('')
process.exit(1)
}
if (dependencies.length) {
console.error(`\nserver/package.json declares ${dependencies.length} runtime dependency(ies):\n`)
for (const d of dependencies) console.error(` ${d}`)
console.error(`\n${DEPENDENCY_ADVICE}`)
process.exit(1)
}
console.log(`OK — ci/bundle.json ships every file server/index.js reaches (${reached} files), and no runtime dependency is declared.`)
}

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env node
// ── §5.1 — zero internal imports ───────────────────────────────────────────
//
// The acceptance test for the whole module contract. A module that reaches into
// core's tree still works — right up until core moves a file — and the boundary
// this workstream exists to build is worth exactly as much as this check is.
//
// MODULE_API.md §5.1 sketches it as a grep for `../../`. That is the shape of
// the violation but not the rule, and the difference matters in both directions:
// a grep says nothing about `require('../../../../etc/passwd')` from a deeply
// nested file (which it catches by accident) and false-alarms on a legitimate
// `require('../module.json')` from `server/` (which it catches wrongly). So this
// RESOLVES each specifier against the file that wrote it and asks whether the
// result is still inside the module root — the actual rule, stated once.
//
// Bare specifiers are checked too, and against a stricter list than "is it
// installed": core hands the module express, express-validator, the database and
// the logger on `ctx` precisely so the module never resolves them, and Node's
// resolver cannot reach core's `node_modules` from here anyway. A bare
// `require` that is not a Node builtin is therefore a module that will fail to
// load on a real install, with a message about a missing package rather than
// about the rule it broke.
//
// **That second check applies to SHIPPED code only.** `test/` and `scripts/`
// never run inside core's process — the fakes in `test/_fakes.js` build a real
// `express` router precisely so the module's routers are exercised for real —
// so they may use devDependencies. The containment check applies everywhere,
// because a test that reaches into core's tree is a test that passes on this
// machine and nowhere else.
//
// Run over the SERVER half. The client half's equivalents are its Vite build,
// which fails if a shared dependency resolves into node_modules, and
// client/scripts/checkExternals.js, which asks the built chunk whether any bare
// specifier survived.
const fs = require('fs')
const path = require('path')
// Node's own answer, not a list reconstructed from `builtinModules`. That list
// omits `test` on Node 20 and includes it on Node 24, so a suite that requires
// `node:test` passed locally and failed in CI on the very first run — reported
// as the module boundary being broken, which it was not. `isBuiltin` is the
// authoritative check and handles the `node:` prefix itself.
const { isBuiltin } = require('module')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
// Packages the SHIPPED half may resolve for itself: this package's declared
// `dependencies`, and nothing else. Read from package.json rather than listed
// here, so adding one is a visible, reviewable edit to the manifest that also
// changes what CI installs and what the release tarball carries.
//
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
// release tarball carries `server/node_modules` because an operator never builds
// — so every entry is weight in the artifact and a package the operator's
// deployment now runs. Anything core already owns must come from `ctx` instead:
// a second express is a second Router prototype, a second express-rate-limit is
// a second store, and a limit enforced by two independent counters is not the
// limit either of them states.
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
// Directories whose contents never run inside core's process, and may therefore
// resolve this package's devDependencies.
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
// need a dependency, and a specifier this pattern misses is a specifier written
// to be missed, which review catches and a stricter regexp would not.
const SPECIFIER = /(?:require\(|from\s+|import\()\s*['"]([^'"]+)['"]/g
/**
* Blank out comments and template literals before scanning.
*
* Not a nicety — without it this file fails on ITSELF, because the comments
* above name `require('../../../../etc/passwd')` as an example of what to
* catch, and index.js explains in prose why it must never `require('express')`.
* A boundary check that cannot survive being described is a check people stop
* writing comments around.
*
* A character walk rather than a regexp, because the two get in each other's
* way: `'https://x'` contains a line-comment opener inside a string, and
* `// don't` contains a quote inside a comment. Tracking the state is shorter
* than the regexp that would almost handle it. Content is replaced with spaces
* rather than removed so nothing else has to care.
*/
function stripCommentsAndTemplates(src) {
let out = ''
let i = 0
const keep = (n) => { out += src.slice(i, i + n); i += n }
const blank = (end) => { out += src.slice(i, end).replace(/[^\n]/g, ' '); i = end }
while (i < src.length) {
const two = src.slice(i, i + 2)
if (two === '//') {
const nl = src.indexOf('\n', i)
blank(nl === -1 ? src.length : nl)
} else if (two === '/*') {
const end = src.indexOf('*/', i + 2)
blank(end === -1 ? src.length : end + 2)
} else if (src[i] === '"' || src[i] === "'") {
// Strings are KEPT — they are where the specifiers live.
const quote = src[i]
keep(1)
while (i < src.length && src[i] !== quote) keep(src[i] === '\\' ? 2 : 1)
keep(1)
} else if (src[i] === '`') {
// Template literals are blanked: nothing may `require` a template, and a
// template holding SQL or HTML is a rich source of false positives.
i += 1
out += ' '
while (i < src.length && src[i] !== '`') {
if (src[i] === '\\') { out += ' '; i += 2 } else { out += src[i] === '\n' ? '\n' : ' '; i += 1 }
}
i += 1
out += ' '
} else {
keep(1)
}
}
return out
}
function* walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name))
} else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
yield path.join(dir, entry.name)
}
}
}
/**
* Every boundary violation under `root`, resolved against `moduleRoot`.
*
* Exported so `test/checkImports.test.js` can point it at fixtures. A check that
* has never been shown to fail is a check nobody knows the state of — and this
* one guards the acceptance criterion for the whole contract.
*/
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
const violations = []
for (const file of walk(root)) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(SPECIFIER)) {
if (specifier.startsWith('.')) {
const resolved = path.resolve(path.dirname(file), specifier)
if (resolved !== moduleRoot && !resolved.startsWith(moduleRoot + path.sep)) {
violations.push({ file, specifier, why: 'escapes the module root' })
}
} else if (path.isAbsolute(specifier)) {
violations.push({ file, specifier, why: 'absolute path' })
} else {
const pkg = specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/')[0]
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
// The `node:` prefix can only ever name a builtin, so it never reaches
// node_modules and is safe whatever this Node version enumerates.
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
if (!builtin && !allowed) {
violations.push({ file, specifier, why: 'undeclared bare specifier — should this come from ctx?' })
}
}
}
}
return violations
}
module.exports = { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT }
// Required by a test, or run as the check? Only the second one exits.
if (require.main !== module) return
const violations = scan(SERVER_ROOT)
if (violations.length) {
console.error(`\n${violations.length} import(s) break the module boundary (MODULE_API.md §5.1):\n`)
for (const v of violations) {
console.error(` ${path.relative(MODULE_ROOT, v.file)}\n "${v.specifier}" — ${v.why}`)
}
console.error('')
process.exit(1)
}
console.log(`OK — no import escapes the module root (${SERVER_ROOT}).`)

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env node
// ── §5.3 — this module's frozen route manifest ─────────────────────────────
//
// Core freezes its URL surface in `server/routes.manifest.json` by walking the
// live Express stack and committing the result; a PR that moves a URL has to
// commit the new manifest, which puts the change in front of a reviewer. The URLs
// this module serves are not in that file. They are here, frozen the same way and
// by the same generator.
//
// **The module's routes are DERIVED, never listed.** This script is handed two
// manifests generated from the SAME core at the pinned ref — one without this
// module on the volume, one with — and the difference is what this module serves.
// Nothing here says "/api/v1/public/rust/*"; a mount prefix appears in exactly one
// place, `server/index.js`'s `registerRoutes` call, which is where an operator's
// core reads it from too.
//
// Taking the difference rather than filtering by prefix buys the other half of
// §5.3 for free, and it is the half that matters most: **no core URL may move.**
// A module that shadowed a core route, or whose mount displaced one, shows up here
// as a removal or a change, not merely as an addition somewhere else. That is the
// promise §1.2 makes to the shipped Android app and the Discord bot.
//
// It is also the only check that can see the blind spot §13's own registration
// comment names: core answers several public routes mounted at the TIER ROOT
// rather than under a prefix — `/status` and `/version` among them — and the
// loader's collision probe cannot find those. `/rust` was checked against core's
// mount tables by hand when phase 1 chose it. From here it is checked by a core.
//
// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json`
// is generated from the module's own registrations against §2.4's stated tier
// bases — the one place a constant could be wrong. Here there is ground truth: a
// real core with this module loaded, reporting the URLs it actually serves. Every
// route must have a documented operation and every documented operation must be a
// route. That is the per-module form of core's standing rule, never ship a route
// that isn't in the spec — and it is what stops a wrong constant in the generator
// from producing a fragment that is internally consistent and describes nothing
// core will ever serve.
//
// Usage (the workflow does the cloning; see .gitea/workflows/pr-checks.yml):
// node scripts/frozenManifest.js --before core-only.json --after core-plus-rust.json
// node scripts/frozenManifest.js --before … --after … --check
const fs = require('fs')
const path = require('path')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json')
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
const COMMENT =
'Generated inventory of the URLs module-rust serves - the module half of the freeze ' +
'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' +
'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' +
'Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see ' +
'server/scripts/frozenManifest.js.'
const key = (r) => `${r.method} ${r.path}`
/**
* The module's routes, plus proof that core's own surface did not move.
*
* @param {object} before routes.manifest.json from core alone
* @param {object} after routes.manifest.json from the same core with this module
* @returns {{ added: object[], removed: string[] }}
*/
function diffManifests(before, after) {
const added = []
const removed = []
for (const tier of ['public', 'internal']) {
const was = new Set((before[tier] || []).map(key))
for (const route of after[tier] || []) {
if (!was.has(key(route))) added.push({ ...route, tier })
was.delete(key(route))
}
for (const gone of was) removed.push(`${tier} ${gone}`)
}
added.sort((a, b) => (key(a) < key(b) ? -1 : 1))
return { added, removed }
}
/**
* Which of the module's routes the fragment fails to document, and vice versa.
*
* Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling
* because that is what core merges, so the manifest's paths are converted here
* rather than the other way round.
*/
function coverage(added, fragment) {
const documented = new Set()
for (const [p, item] of Object.entries(fragment.paths || {})) {
for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`)
}
const undocumented = []
for (const route of added) {
const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
if (documented.has(oas)) documented.delete(oas)
else undocumented.push(oas)
}
// Whatever is left is documented and not served: a route that moved or was
// deleted while its annotation stayed behind. Core's own spec has no equivalent
// check and grew four orphan tags and thirty-three orphan schemas because of it.
return { undocumented, unserved: [...documented].sort() }
}
function serialize(routes) {
return `${JSON.stringify(
{
$comment: COMMENT,
routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })),
},
null,
2,
)}\n`
}
function main() {
const arg = (name) => {
const i = process.argv.indexOf(name)
return i === -1 ? null : process.argv[i + 1]
}
const beforePath = arg('--before')
const afterPath = arg('--after')
if (!beforePath || !afterPath) {
process.stderr.write('usage: frozenManifest.js --before <manifest> --after <manifest> [--check]\n')
process.exit(2)
}
const before = JSON.parse(fs.readFileSync(beforePath, 'utf8'))
const after = JSON.parse(fs.readFileSync(afterPath, 'utf8'))
const { added, removed } = diffManifests(before, after)
let failed = false
if (removed.length > 0) {
process.stderr.write(
`\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` +
`${removed.map((r) => ` - ${r}`).join('\n')}\n` +
'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n',
)
failed = true
}
if (added.length === 0) {
process.stderr.write(
'\nLoading this module added NO routes. Either it failed to load in the core checkout\n' +
'(check the boot log for a startup_failed line) or the two manifests are the same file.\n',
)
process.exit(1)
}
const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
const { undocumented, unserved } = coverage(added, fragment)
if (undocumented.length > 0) {
process.stderr.write(
`\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` +
`${undocumented.map((r) => ` - ${r}`).join('\n')}\n` +
'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n',
)
failed = true
}
if (unserved.length > 0) {
process.stderr.write(
`\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` +
`${unserved.map((r) => ` - ${r}`).join('\n')}\n` +
'A documented URL nobody serves is a client following the docs into a 404.\n',
)
failed = true
}
if (failed) process.exit(1)
const contents = serialize(added)
if (process.argv.includes('--check')) {
const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null
if (current !== contents) {
process.stderr.write(
'\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' +
'commit the result so the move is reviewed rather than merged as mechanical.\n',
)
process.exit(1)
}
process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`)
return
}
fs.writeFileSync(MANIFEST, contents)
process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`)
}
if (require.main === module) main()
module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }

View File

@@ -0,0 +1,300 @@
#!/usr/bin/env node
// ── §2.8 — the OpenAPI fragment ───────────────────────────────────────────
//
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
// tags and schemas describing every route this module registers. Core merges the
// fragments of *started* modules over its own committed spec at request time and
// serves the result at `/api/docs.json` (MODULE_API.md §6.1a).
//
// ── Why a module has to ship this at all ──────────────────────────────────
//
// Core's own spec generation is STATIC analysis — swagger-autogen parses core's
// `app.js` as text and follows the literal `app.use(...)` chain. Your module
// arrives on a volume after core was built, is required by a filesystem loop, and
// mounts through `api.registerRoutes()`. There is no literal mount for a parser to
// follow, and core does not have your sources anyway. So nothing core can run
// will ever describe your routes.
//
// The failure mode is the dangerous one: swagger-autogen reports success and
// emits a spec with the routes simply absent. It happened twice inside core
// before anyone noticed, and once to the first module — 417 annotations that
// generated nothing at all, for two phases, because nobody had built the
// fragment. If you take one thing from this file, take that a green build is not
// evidence that anything was described.
//
// ── Where the prefixes come from ──────────────────────────────────────────
//
// swagger-autogen is pointed at one router file at a time, so its paths come out
// relative to that router (`/status`, not `/api/v1/public/world/status`) —
// nothing in the file says where it hangs. §6.1a requires fully-qualified paths,
// because core merges the fragment verbatim and never re-derives a prefix.
//
// So this script **runs your own `register()`** against a recording `api` and
// reads the mounts back out. Every prefix is therefore the prefix that router is
// actually registered under — the same call an operator's core will make, rather
// than a table beside it that drifts the first time a mount moves. Which file a
// recorded router object came from is answered by `require.cache`: the module
// whose `exports` IS that router.
//
// The tier base paths are the one thing that cannot be derived here, because they
// are core's and not yours. They are §2.4's normative table, quoted below.
const fs = require('fs')
const os = require('os')
const path = require('path')
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const { fakeCtx, fakeApi } = require('../test/_fakes')
const doc = require('../swagger/doc')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
// router in core, behind its gate; the base path is core's and fixed.
const TIER_BASE = {
public: '/api/v1/public',
admin: '/api/v1/admin',
player: '/api/v1/player',
}
// MODULE_API.md §2.4's slot table, and the FOURTH base this generator needs.
//
// Phase 6 found the hole: a slot router is not registered under a tier, so the
// loop below could not see it and the two routes it serves were generated by
// nothing — a fragment that was internally consistent and silently described two
// routes fewer than the module serves. The frozen-manifest check would have
// caught it (every route must have an operation), which is precisely why that
// check exists; this is the fix it points at.
//
// A slot's mount is CORE's, not ours, so it cannot be derived from anything in
// this repo. That makes it the same kind of constant as `TIER_BASE` above, and it
// is held to account the same way: by a real core in the frozen-manifest job.
const SLOT_MOUNT = {
'admin.users.detail': '/api/v1/admin/users/:id',
}
// A router mounted INSIDE a registered one with `use()` needs nothing here, and
// that was worth finding out: swagger-autogen reads a FILE and follows its
// `require`s, so `/rust/permissions` is generated with the right prefix from
// `rust.router.js` alone. It is the opposite of the hole phase 6 found with the
// slot — the registration walk cannot see a nested router, and the generator can.
//
// A nested router exists at all because a mount prefix is ONE path segment
// (core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`), so `/rust/permissions` cannot
// be declared in `module.json` and has to be a `use()` under `/rust`.
/**
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
*
* The ctx is the test fakes' — the same one the suite proves the module runs
* against — because registration must not touch a database (§2.2), and this
* script is exactly the kind of no-database caller that rule exists for.
*/
function mountedRouters() {
const register = require('../index')
const api = fakeApi()
register(fakeCtx(), api)
const fileOf = (router) => {
for (const mod of Object.values(require.cache)) {
if (mod && mod.exports === router) return mod.filename
}
return null
}
const mounts = []
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
const base = TIER_BASE[tier]
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
for (const [prefix, router] of Object.entries(byPrefix)) {
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
}
}
// A filled slot is a mount too. Registered through a different call, mounted
// on a resource core owns, and — unlike a tier router — carrying the parent's
// `:id` in its own base path.
for (const { slot, router } of api.record.extensions || []) {
const mount = SLOT_MOUNT[slot]
if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`)
mounts.push({ router, prefix: mount, what: `slot ${slot}` })
}
return mounts.map(({ router, prefix, what }) => {
const file = fileOf(router)
if (!file) {
// A router built inline in index.js rather than required from its own
// file. swagger-autogen needs a file to read, so there is nothing to
// generate from — put the router in its own module.
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
}
return { file, prefix, what }
})
}
/**
* Run swagger-autogen over one router file. Paths come out router-relative.
*
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
* `console.error`s "Syntax error" or "out of structure", drops that one
* annotation, and prints `Success` in green. So its diagnostics are captured here
* and made fatal. Nothing else will tell you.
*/
async function fragmentFor(file) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-'))
const out = path.join(dir, 'fragment.json')
const complaints = []
const realError = console.error
console.error = (...args) => {
const line = args.map(String).join(' ')
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
else realError(...args)
}
try {
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
// writes its result back into the object it was handed, so reusing one `doc`
// across several routers re-wraps the previous pass's output every time. The
// first module to hit this produced a 484 MB fragment from six routers.
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
...JSON.parse(JSON.stringify(doc)),
info: { title: 'examplegame fragment', version: '0' },
})
} finally {
console.error = realError
}
if (complaints.length > 0) {
throw new Error(
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
)
}
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
fs.rmSync(dir, { recursive: true, force: true })
return fragment
}
/**
* Re-root a router-relative fragment under the prefix it is mounted at.
*
* Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging
* to the PREFIX is moved to the front of each operation's parameter list —
* swagger-autogen orders parameters by where they appeared in the path it saw,
* which was only the tail.
*/
function prefixPaths(fragment, prefix) {
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
const paths = {}
for (const [p, item] of Object.entries(fragment.paths || {})) {
for (const operation of Object.values(item)) {
const params = operation && operation.parameters
if (!Array.isArray(params)) continue
const rank = (q) => {
const i = outer.indexOf(q && q.name)
return i === -1 ? outer.length : i
}
operation.parameters = params
.map((q, i) => ({ q, i }))
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
.map(({ q }) => q)
}
// `router.get('/')` under a prefix concatenates to a trailing slash, a URL no
// client calls. Core's generator normalises the same way.
paths[`${oas}${p}`.replace(/\/$/, '')] = item
}
return paths
}
/**
* Build the whole fragment: every mounted router, re-rooted and merged.
*
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a
* fragment carry. `info`, `servers` and the security schemes belong to the merged
* document, which is to say to core.
*/
async function build() {
const spec = { paths: {}, tags: [], components: { schemas: {} } }
let shared = false
for (const { file, prefix, what } of mountedRouters()) {
const generated = await fragmentFor(file)
// Tags and schemas are the same on every pass — each was handed the same
// `doc` — so take them from whichever ran first. What lands in the fragment
// has to be what swagger-autogen PRODUCED and not what it was given: those
// two differ (see fragmentFor), and core merges this file verbatim into a
// spec whose own schemas went through the same mill.
if (!shared) {
spec.tags = generated.tags || []
spec.components.schemas = (generated.components || {}).schemas || {}
shared = true
}
const paths = prefixPaths(generated, prefix)
const count = Object.keys(paths).length
if (count === 0) {
// An empty result is precisely what the silent drop looks like, so it is a
// hard failure rather than a router that happens to declare no routes.
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
}
for (const [p, item] of Object.entries(paths)) {
if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`)
spec.paths[p] = item
}
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix}${what}\n`)
}
// Sorted, because swagger-autogen emits router-traversal order: without this,
// moving a route between files rewrites most of a committed artifact even when
// the API is provably unchanged.
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
return spec
}
async function main() {
const check = process.argv.includes('--check')
const spec = await build()
const json = `${JSON.stringify(spec, null, 2)}\n`
if (!check) {
fs.writeFileSync(FRAGMENT, json)
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)}${Object.keys(spec.paths).length} paths\n`)
return
}
if (!fs.existsSync(FRAGMENT)) {
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
process.exit(1)
}
// Compared with line endings normalised, and that is not fussiness. A default
// Windows clone checks this file out as CRLF while the generator above writes
// LF, so a byte comparison failed on a PRISTINE template and told the reader
// their routes had changed — the kit's acceptance run lost ten minutes to it
// before reaching for `od -c` (docs/modules/kit-acceptance.md, F1). A check may
// only fail for the reason it names; this one names a diagnosis, so it has to
// be right about it. `.gitattributes` stops the CRLF from arriving in the first
// place, and this stops it mattering if it does.
const lf = (s) => s.replace(/\r\n/g, '\n')
if (lf(fs.readFileSync(FRAGMENT, 'utf8')) !== lf(json)) {
process.stderr.write(
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
'so a stale one documents a URL surface this module does not serve.\n',
)
process.exit(1)
}
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
}
if (require.main === module) {
main().catch((err) => {
process.stderr.write(`${err.stack}\n`)
process.exit(1)
})
}
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }

294
server/sidecarClient.js Normal file
View File

@@ -0,0 +1,294 @@
// ── The near end of a call whose far end is a Rust server ─────────────────
//
// Every other file in this module reads its own tables. This one is different in
// kind: it is the only place that leaves the process.
//
// **The website process never opens a connection to a game server**
// (MODULE_API.md §2.7). It opens one to a `rust-link` sidecar, which owns the
// socket to the game, persists what the game says before forwarding it, and
// answers reads from that store. `test/noGameConnection.test.js` enforces the
// decidable half of that rule and names this file as the one that may reach the
// network:
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// ── One client per configured server ──────────────────────────────────────
//
// R8: the bridge is one game server to one sidecar. So this file takes the
// server row as an argument rather than holding a single configured endpoint —
// six servers is six base URLs and six tokens, and core never learns there is
// more than one.
//
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
//
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
// the budget expires it stops waiting and classifies the failure as **retry**,
// unconditionally, without asking the action — it cannot ask, the action is still
// awaiting a socket. So if core's deadline is shorter than this one, an action
// never gets to classify its own failure and `{ ok: false, retry: false }` is
// unreachable code. `budgetMs` must EXCEED this.
//
// It is also bounded from the other side: the sidecar's own RPC reply timeout is
// ten seconds, so a value below that would give up while the sidecar is still
// legitimately waiting for the game. The ordering is
// `sidecar RPC timeout < TIMEOUT_MS < budgetMs`, and every one of the three
// is written down somewhere the other two can be checked against.
//
// ── This file never throws ────────────────────────────────────────────────
//
// Every call answers `{ ok, status, data }`. A module that let a socket failure
// escape into a controller would hand an exception to a page whose whole job is
// to render while the game is off. The public site degrades; it does not 500.
const core = require('./core')
const log = core.logger('sidecar')
/** How long this client waits before giving up on a sidecar. See the header. */
const TIMEOUT_MS = 12000
/**
* The wire version this module speaks. Declared in FOUR places that must agree:
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
* **6 — first-party clans.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`), 4 the first that writes to
* the game's permission store, 5 the first that writes to the game HOST'S
* FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
* built from, and no route at all. The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
* default; it is an outage with a version number on it.
*
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 6
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
return { ok, status, data }
}
/**
* Normalises a configured base URL into something `new URL(path, base)` will not
* surprise anybody with.
*
* A trailing slash on the base and a leading slash on the path is the classic
* way to lose a path segment, and an operator pasting a URL out of a terminal
* supplies the trailing slash about half the time.
*/
function joinUrl(baseUrl, path) {
return `${String(baseUrl).replace(/\/+$/, '')}${path}`
}
/**
* One request to one sidecar.
*
* @param {object} server a `rust_servers` row, token already decrypted
* @param {string} server.baseUrl
* @param {string|null} server.token
* @param {string} path e.g. `/server`
* @param {object} [options]
* @param {string} [options.method]
* @param {object} [options.body]
*/
async function request(server, path, { method = 'GET', body = null } = {}) {
if (!server || !server.baseUrl) return reply(false, 'not-configured')
// A sidecar with auth off does not exist — it generates and persists a token on
// first start — so a missing token here is a half-finished admin form, not a
// sidecar to try unauthenticated. Saying so beats a 401 the operator has to
// interpret.
if (!server.token) return reply(false, 'no-token')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const res = await fetch(joinUrl(server.baseUrl, path), {
method,
signal: controller.signal,
headers: {
Authorization: `Bearer ${server.token}`,
'X-RustLink-Version': String(PROTOCOL_VERSION),
...(body ? { 'Content-Type': 'application/json' } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
})
// A protocol mismatch is a deployment fault and deserves its own status, not
// to be folded into "the sidecar said no". The operator's fix is an upgrade
// of one component, and the message has to be able to say which.
if (res.status === 409) {
const detail = await safeJson(res)
log.warn('protocol mismatch', {
server: server.id,
module: PROTOCOL_VERSION,
sidecar: detail && detail.sidecar_protocol,
})
return reply(false, 'protocol-mismatch', detail)
}
if (res.status === 401) return reply(false, 'unauthorized')
// 204 is an ANSWER, not an absence of one: the sidecar is up and reports that
// the game has never connected. Collapsing it into a failure would make a
// freshly installed server indistinguishable from an unreachable one.
if (res.status === 204) return reply(true, 'empty', null)
if (!res.ok) return reply(false, `http-${res.status}`)
return reply(true, 'ok', await safeJson(res))
} catch (err) {
// `AbortError` is this client's own deadline firing, and it is worth telling
// apart from a refused connection: one means the sidecar is slow or the game
// is not answering, the other means nothing is listening.
const status = err && err.name === 'AbortError' ? 'timeout' : 'transport-error'
log.warn('sidecar request failed', { server: server.id, path, status, error: err.message })
return reply(false, status)
} finally {
clearTimeout(timer)
}
}
async function safeJson(res) {
try {
return await res.json()
} catch {
// A sidecar that answered 200 with something that is not JSON is a sidecar
// this module cannot use, but it is not a reason to throw at a page.
return null
}
}
/** Liveness, the protocol version, and whether the plugin is connected. Unauthenticated at the far end, but sent authenticated anyway so one code path covers every call. */
const health = (server) => request(server, '/health')
/** The last `server.hello` the sidecar stored. Answers while the game is off. */
const serverBoard = (server) => request(server, '/server')
/** A live round trip through the sidecar to the game. Fails when the game is down, by design. */
const liveStatus = (server) => request(server, '/status')
/** Every board at once: what is true now, before following what happens next. */
const boards = (server) => request(server, '/boards')
/**
* The ingest cursor: events after `since`, oldest first.
*
* **`since` is required here, unlike on the wire.** The sidecar treats an omitted
* cursor as "tell me where the end is", which is a genuinely useful question and
* a catastrophic default for an ingest loop that would silently store nothing
* and advance past everything. So the question is asked explicitly, by name, and
* a caller cannot get it by forgetting an argument.
*/
const feed = (server, since, limit = 200) =>
request(server, `/feed?since=${encodeURIComponent(since)}&limit=${encodeURIComponent(limit)}`)
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
const feedTail = (server) => request(server, '/feed')
/**
* Redeem a one-time link code against one server (protocol 3).
*
* **The only call in this file that is not a GET**, and the only one that asks
* the game a question rather than reading what it already said. The sidecar
* forwards the code to the plugin, which holds the pending codes in memory, and
* hands back what it answers.
*
* **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are
* both answers — the sidecar reserves its own failures for the transport (503
* when the game is down, 504 when it is up and silent) — and the caller has to
* tell "that code is wrong" from "the game never replied" to say the right thing
* to a player. So the discrimination happens on `data.kind`, not on `ok`.
*
* A code is spent on the plugin's FIRST lookup whether or not it turns out to be
* expired, so this must never be called speculatively for its answer alone.
*/
const confirmLink = (server, code) =>
request(server, '/link/confirm', { method: 'POST', body: { code } })
/**
* What one server's loaded plugins have registered, and the groups its store
* holds (protocol 4).
*
* The option source behind the authoring form (D33). It is a live read through
* to the game rather than anything cached at the sidecar, because the answer
* changes when an operator loads a plugin — and the whole reason to ask is to
* offer names that will actually resolve. It therefore fails when the game is
* down, like `/status` and unlike every store-backed read.
*/
const permCatalogue = (server) => request(server, '/permissions/catalogue')
/**
* Push the whole permission set this site authors for one server (protocol 4).
*
* **The second call in this file that is not a GET, and the first that changes
* the game.** The body is the desired set plus what the site has withdrawn; the
* plugin diffs it against the live store, applies the difference and answers
* with a report — counts, the names it could not resolve, the memberships that
* are waiting on a first connection, and every holder the site did not author.
*
* **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error`
* and `perm.report` are both answers, and the sidecar keeps its own status codes
* for the transport. The caller discriminates on `data.kind`.
*/
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
/**
* Every settings file on one game host, and every plugin loaded to reload one
* (protocol 5, R18).
*
* A description of the tree, never its contents: paths, sizes, which files are
* too large to edit, and the plugin each one probably belongs to. **Probably**
* is the operative word and it survives all the way to the form — a folder name
* is convention, not contract, and reloading the wrong plugin would report
* success while the edited one never re-read anything.
*
* Live, like `/status`: what is on a host's disk has no stale answer worth
* giving, and a cached one would be an edit an operator made over SSH that the
* website then overwrote.
*/
const configFiles = (server) => request(server, '/config/files')
/** One settings file as text, with the version a write has to present back. */
const configFile = (server, path) =>
request(server, `/config/file?path=${encodeURIComponent(path)}`)
/**
* Replace a set of settings files and reload what owns them (protocol 5).
*
* **The only call in this module that writes to a filesystem**, and the only one
* whose reply routinely takes seconds: the plugin holds it open across the
* reload it is watching, and across the rollback if that reload never arrives.
*
* Like every other write on this bridge, a refusal comes back `{ ok: true }`
* with the answer in `data.kind` — `config.report` or `config.error`. The
* transport keeps its own codes, and a `504` here is the one case worth reading
* carefully: the plugin writes a whole set or restores a whole set, never half
* of either, so the state is knowable by re-reading rather than by guessing.
*/
const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body })
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
request,
health,
serverBoard,
liveStatus,
boards,
feed,
feedTail,
confirmLink,
permCatalogue,
permSync,
configFiles,
configFile,
configWrite,
joinUrl,
}

684
server/swagger/doc.js Normal file
View File

@@ -0,0 +1,684 @@
// ── The OpenAPI fragment: the shared half ─────────────────────────────────
//
// The tags and component schemas the `#swagger.*` annotations refer to.
// `scripts/swaggerFragment.js` feeds this to swagger-autogen; the per-endpoint
// detail lives beside each route, exactly as it does in core.
//
// **Two rules about names, and both belong to the MERGED document rather than to
// this file** (MODULE_API.md §6.1a). Core merges every started module's fragment
// over its own committed spec and serves the result at `/api/docs.json`, and core
// wins any key collision:
//
// • **Namespace what you DEFINE.** `RustServerList`, not `ServerList`. A second
// game's module describing the same idea under the same bare name would
// silently clobber this one or be clobbered by it.
// • **Reference what CORE defines by core's name.** `#/components/schemas/Error`
// and `ValidationError` are core's; point at them and do not redefine them.
//
// **swagger-autogen renders `components.schemas` from an EXAMPLE object, not from
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
// That is uniform across core's committed spec and is the house shape.
module.exports = {
tags: [
{
name: 'Public · Rust',
description: 'The Rust servers this site follows, as each one last reported itself',
},
{
name: 'Player · Rust',
description: 'The Rust surface for a signed-in player',
},
{
name: 'Admin · Rust',
description: 'Configuring the Rust servers and their sidecars',
},
],
components: {
schemas: {
RustServerList: {
type: 'object',
description: 'Every Rust server this site follows (GET /public/rust/servers).',
properties: {
servers: {
type: 'array',
items: { $ref: '#/components/schemas/RustServer' },
},
},
},
RustServer: {
type: 'object',
description: 'One Rust server, as it last reported itself.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
online: { type: 'boolean', example: true },
players: { type: 'integer', example: 42 },
maxPlayers: { type: 'integer', example: 100 },
hostname: { type: 'string', nullable: true, example: 'Runic Gateway · Main' },
level: { type: 'string', nullable: true, example: 'Procedural Map' },
worldSize: { type: 'integer', nullable: true, example: 4000 },
seed: { type: 'integer', nullable: true, example: 1234 },
updatedAt: { type: 'string', format: 'date-time', nullable: true },
stale: {
type: 'boolean',
description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.',
example: false,
},
},
},
RustAdminServerList: {
type: 'object',
description: 'The configured servers, with their sidecar settings (GET /admin/rust/servers).',
properties: {
servers: {
type: 'array',
items: { $ref: '#/components/schemas/RustAdminServer' },
},
},
},
RustAdminServer: {
type: 'object',
description: 'One configured server. The sidecar token is never included — `hasToken` reports only whether one is stored.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
sidecarBaseUrl: { type: 'string', example: 'http://10.0.0.5:8090' },
hasToken: { type: 'boolean', example: true },
protocol: { type: 'integer', example: 1 },
enabled: { type: 'boolean', example: true },
sortOrder: { type: 'integer', example: 0 },
reachable: {
type: 'boolean',
description: 'Did the sidecar answer on the last poll? Separate from `online`, which is about the game rather than the bridge.',
example: true,
},
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
online: { type: 'boolean', example: true },
players: { type: 'integer', example: 42 },
stale: { type: 'boolean', example: false },
},
},
RustLink: {
type: 'object',
description: 'One Steam account linked to a website user. Never carries a code.',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: {
type: 'string',
nullable: true,
description: 'What the player was called in game when they linked. A display name only — a Rust name changes on a whim and nothing identifies anybody by it.',
example: 'Wanderer',
},
serverId: {
type: 'string',
nullable: true,
description: 'Which server minted the code. Not part of the identity — a link is fleet-wide — but it is where a support conversation starts.',
example: 'main',
},
linkedAt: { type: 'string', format: 'date-time' },
},
},
RustLinkList: {
type: 'object',
description: 'The Steam accounts one website user holds (GET /player/rust/links).',
properties: {
links: { type: 'array', items: { $ref: '#/components/schemas/RustLink' } },
},
},
RustPlayerReach: {
type: 'object',
description: 'One server an entitlements scope reaches, and whether it is there yet.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla+' },
live: {
type: 'boolean',
description: 'True only when a sync confirmed this into that servers own store. False covers every way it has not arrived — the server is offline, no loaded plugin registered the name, or its store has never seen the account — and the difference between those is an operators diagnosis, not a players.',
example: true,
},
},
},
RustPlayerPermissions: {
type: 'object',
description: 'What the site has given the signed-in player in game (GET /player/rust/permissions).',
properties: {
accounts: {
type: 'integer',
description: 'How many Steam accounts the caller has linked. Zero is why an entitlement can be authored and reach nobody.',
example: 1,
},
groups: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
scope: { type: 'string', description: 'A server id, or `*` for the whole fleet.', example: '*' },
since: { type: 'string', format: 'date-time' },
permissions: { type: 'array', items: { type: 'string' }, example: ['kits.vip'] },
reach: { type: 'array', items: { $ref: '#/components/schemas/RustPlayerReach' } },
},
},
},
grants: {
type: 'array',
items: {
type: 'object',
properties: {
permission: { type: 'string', example: 'kits.vip' },
scope: { type: 'string', example: '*' },
source: { type: 'string', description: 'Who authored it — `admin` now, an event action later.', example: 'admin' },
note: { type: 'string', nullable: true },
since: { type: 'string', format: 'date-time' },
reach: { type: 'array', items: { $ref: '#/components/schemas/RustPlayerReach' } },
},
},
},
},
},
RustLinkRequest: {
type: 'object',
required: ['code'],
properties: {
code: {
type: 'string',
description: 'The six-character code /link handed the player in game. Good for five minutes, and it works once.',
example: 'K7M2PQ',
},
},
},
RustLinkResult: {
type: 'object',
description: 'The result of redeeming a code.',
properties: {
linked: { type: 'boolean', example: true },
link: { $ref: '#/components/schemas/RustLink' },
already: {
type: 'boolean',
description: 'True when this Steam id was already linked to the caller — a second press of the button, not an error.',
example: false,
},
},
},
RustAdminLinkList: {
type: 'object',
description: 'One users Rust identity, for the admin.users.detail panel (GET /admin/users/{id}/rust/links).',
properties: {
links: {
type: 'array',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: {
type: 'string',
nullable: true,
description: 'What the game last saw this player called, falling back to the name recorded at link time.',
example: 'Wanderer',
},
linkedName: { type: 'string', nullable: true, example: 'Wanderer' },
serverId: { type: 'string', nullable: true, example: 'main' },
linkedAt: { type: 'string', format: 'date-time' },
firstSeen: { type: 'string', format: 'date-time', nullable: true },
lastSeen: { type: 'string', format: 'date-time', nullable: true },
servers: {
type: 'array',
description: 'All-time totals per server, summed across every wipe.',
items: {
type: 'object',
properties: {
serverId: { type: 'string', example: 'main' },
serverName: { type: 'string', example: 'Main · Vanilla' },
kills: { type: 'integer', example: 41 },
deaths: { type: 'integer', example: 37 },
npcKills: { type: 'integer', example: 120 },
structures: { type: 'integer', example: 64 },
playtimeSec: { type: 'integer', example: 43200 },
wipes: { type: 'integer', example: 2 },
lastSeen: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
},
},
},
},
RustPermissionModel: {
type: 'object',
description:
'The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use.',
properties: {
groups: {
type: 'array',
description: 'Groups the site authors, mirrored into each in-scope game as a real group.',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
rank: { type: 'integer', example: 10 },
scope: {
type: 'string',
description: 'A server id, or `*` for every server.',
example: '*',
},
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
members: {
type: 'array',
items: {
type: 'object',
properties: {
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
steamId: {
type: 'string',
nullable: true,
description: 'Null when this account has linked no Steam id, in which case the membership reaches nobody yet.',
example: '76561198000000000',
},
playerName: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
grants: {
type: 'array',
description: 'Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 7 },
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
permission: { type: 'string', example: 'kits.gold' },
scope: { type: 'string', example: 'main' },
source: {
type: 'string',
description: 'What authored it — `admin`, `adopted`, or a later phases own writer.',
example: 'admin',
},
note: { type: 'string', nullable: true, example: null },
grantedAt: { type: 'string', format: 'date-time' },
accounts: {
type: 'array',
description: 'The Steam accounts this grant reaches. Empty means it reaches nobody yet.',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
servers: {
type: 'array',
description: 'The state of the mirror, per configured server.',
items: { $ref: '#/components/schemas/RustPermissionSyncState' },
},
drift: {
type: 'array',
description: 'What a game holds that the site did not author. Reported, never undone.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 3 },
serverId: { type: 'string', example: 'main' },
kind: {
type: 'string',
description: 'One of `grant`, `member`, `group-permission`.',
example: 'grant',
},
subject: {
type: 'string',
description: 'A Steam id, or a group name.',
example: '76561198000000000',
},
object: {
type: 'string',
description: 'A permission name, or a group name.',
example: 'kits.admin',
},
username: {
type: 'string',
nullable: true,
description: 'The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked.',
example: 'wanderer',
},
firstSeen: { type: 'string', format: 'date-time' },
},
},
},
catalogue: {
type: 'array',
items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
},
},
},
RustPermissionSyncState: {
type: 'object',
description: 'Whether one servers store matches what the site authors, and what its last report said.',
properties: {
serverId: { type: 'string', example: 'main' },
state: {
type: 'string',
description: 'One of `pending`, `ok`, `failed`.',
example: 'ok',
},
inSync: {
type: 'boolean',
description: 'True when the last successful push carried the set the site currently authors.',
example: true,
},
dirty: { type: 'boolean', example: false },
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
error: {
type: 'string',
nullable: true,
description: 'Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the games own refusal.',
example: null,
},
report: {
type: 'object',
nullable: true,
description: 'The plugins report from the last successful sync.',
properties: {
applied: {
type: 'object',
properties: {
grants: { type: 'integer', example: 2 },
revokes: { type: 'integer', example: 0 },
groupsCreated: { type: 'integer', example: 1 },
members: { type: 'integer', example: 3 },
},
},
alreadyCorrect: { type: 'integer', example: 14 },
unresolved: {
type: 'array',
description: 'Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed.',
items: { type: 'string', example: 'kits.gold' },
},
pending: {
type: 'array',
description: 'Memberships waiting on a first connection: the store has no user record to put in a group yet.',
items: { type: 'string', example: '76561198000000000:vip' },
},
},
},
},
},
RustPermissionCatalogue: {
type: 'object',
description: 'Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue).',
properties: {
permissions: {
type: 'array',
items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
},
},
},
RustPermissionCatalogueEntry: {
type: 'object',
description: 'One registered permission name, and which servers know it.',
properties: {
permission: { type: 'string', example: 'kits.vip' },
servers: { type: 'array', items: { type: 'string', example: 'main' } },
},
},
RustPermissionSyncResult: {
type: 'object',
description: 'What a forced sync produced (POST /admin/rust/permissions/sync).',
properties: {
servers: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionSyncState' } },
drift: { type: 'array', items: { type: 'object' } },
},
},
RustUserPermissions: {
type: 'object',
description: 'One persons Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions).',
properties: {
groups: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
scope: { type: 'string', example: '*' },
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
},
},
},
grants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 7 },
permission: { type: 'string', example: 'kits.gold' },
scope: { type: 'string', example: 'main' },
source: { type: 'string', example: 'admin' },
grantedAt: { type: 'string', format: 'date-time' },
},
},
},
reaches: {
type: 'array',
description: 'The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only.',
items: { type: 'string', example: '76561198000000000' },
},
},
},
RustOnline: {
type: 'object',
description: 'Who is on one server (GET /public/rust/servers/{id}/online). Below the operators presence audience the names are withheld and only the count is answered — nothing names who is online by default.',
properties: {
players: {
type: 'array',
description: 'Empty whenever `hidden` is true.',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', nullable: true, example: 'Wanderer' },
sleeping: { type: 'boolean', example: false },
connectedAt: { type: 'string', nullable: true },
},
},
},
hidden: { type: 'boolean', description: 'Were the names withheld from this viewer?', example: true },
count: { type: 'integer', description: 'How many are online. Public at every audience.', example: 12 },
audience: { $ref: '#/components/schemas/RustAudience' },
},
},
RustAudience: {
type: 'string',
enum: ['staff', 'signed_in', 'public'],
description: 'Who may see something: admins and moderators, any signed-in account, or anybody. Ordered — each includes the ones before it.',
example: 'staff',
},
RustVisibility: {
type: 'object',
description: 'Who may see who is online: the fleet default and each servers optional override (GET /admin/rust/visibility).',
properties: {
audiences: { type: 'array', items: { $ref: '#/components/schemas/RustAudience' } },
presence: {
type: 'object',
properties: {
fleet: { $ref: '#/components/schemas/RustAudience' },
servers: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
enabled: { type: 'boolean', example: true },
override: {
type: 'string',
nullable: true,
enum: ['staff', 'signed_in', 'public', null],
description: 'This servers own choice, or null to follow the fleet default.',
},
effective: { $ref: '#/components/schemas/RustAudience' },
},
},
},
},
},
clans: {
type: 'object',
description: 'Who may see a clan roster, and each servers clan board.',
properties: {
audiences: { type: 'array', items: { $ref: '#/components/schemas/RustClanAudience' } },
roster: { $ref: '#/components/schemas/RustClanAudience' },
servers: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
supported: { type: 'boolean', example: true },
enabled: { type: 'boolean', example: true },
fresh: { type: 'boolean', example: true },
truncated: { type: 'boolean', example: false },
reason: { type: 'string', nullable: true },
clans: { type: 'integer', example: 14 },
umodClans: { type: 'boolean', description: 'Is the uMod Clans plugin loaded? Its clans are a separate system and are not Teams.', example: false },
},
},
},
},
},
},
},
RustClanAudience: {
type: 'string',
enum: ['members', 'signed_in', 'public'],
description: 'Who may see a clans roster: the clans own members (a website account linked to one of them) and staff, any signed-in account, or anybody. Widening it also shows which members are online to that audience.',
example: 'members',
},
RustClanBoard: {
type: 'object',
description: 'Whether a servers clan list can be trusted right now.',
properties: {
supported: { type: 'boolean', description: 'Could the bridge read this servers clans at all?', example: true },
enabled: { type: 'boolean', description: 'Is the games clan system switched on?', example: true },
fresh: { type: 'boolean', description: 'Has the board been re-sent within the last three minutes?', example: true },
truncated: { type: 'boolean', description: 'At the games 100-clan ceiling, or too large for one line: there may be clans the list does not show.', example: false },
reason: { type: 'string', nullable: true, description: 'Why the clans cannot be read, when they cannot.' },
},
},
RustClanList: {
type: 'object',
description: 'One servers clans (GET /public/rust/servers/{id}/clans). Public: nothing here names a player.',
properties: {
clans: {
type: 'array',
items: {
type: 'object',
properties: {
externalId: { type: 'string', example: 'main:12:1790142840535' },
name: { type: 'string', example: 'Northwatch' },
color: { type: 'string', nullable: true, example: '#3fa9f5' },
score: { type: 'integer', example: 140 },
memberCount: { type: 'integer', example: 6 },
maxMembers: { type: 'integer', nullable: true, example: 100 },
},
},
},
board: { $ref: '#/components/schemas/RustClanBoard' },
},
},
RustClan: {
type: 'object',
description: 'One clan (GET /public/rust/clans/{externalId}) and, inside the roster audience, its roster.',
properties: {
clan: {
type: 'object',
properties: {
externalId: { type: 'string', example: 'main:12:1790142840535' },
name: { type: 'string', example: 'Northwatch' },
color: { type: 'string', nullable: true, example: '#3fa9f5' },
score: { type: 'integer', example: 140 },
memberCount: { type: 'integer', example: 6 },
maxMembers: { type: 'integer', nullable: true, example: 100 },
serverId: { type: 'string', example: 'main' },
serverName: { type: 'string', example: 'Main · Vanilla' },
founded: { type: 'integer', nullable: true, description: 'When the clan was founded, epoch milliseconds.' },
gone: { type: 'boolean', description: 'The clan has been disbanded, or has left its servers board.', example: false },
},
},
roster: {
type: 'object',
properties: {
visible: { type: 'boolean', description: 'Is this viewer inside the roster audience? When false, `members` is empty.', example: false },
audience: { $ref: '#/components/schemas/RustClanAudience' },
members: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', nullable: true, example: 'Wanderer' },
role: { type: 'string', nullable: true, example: 'Leader' },
leader: { type: 'boolean', example: true },
online: { type: 'boolean', example: false },
joined: { type: 'integer', nullable: true, description: 'Epoch milliseconds.' },
},
},
},
},
},
},
},
RustVisibilityUpdate: {
type: 'object',
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
properties: {
fleet: { $ref: '#/components/schemas/RustAudience' },
servers: {
type: 'object',
additionalProperties: { type: 'string', nullable: true, enum: ['staff', 'signed_in', 'public', null] },
example: { main: 'public', pvp: null },
},
clanRoster: { $ref: '#/components/schemas/RustClanAudience' },
},
},
RustSidecarProbe: {
type: 'object',
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
properties: {
ok: { type: 'boolean', example: true },
status: {
type: 'string',
description: 'What happened, in one word — this is what tells a wrong URL from a wrong token from a mismatched protocol. One of `ok`, `no-token`, `unauthorized`, `protocol-mismatch`, `timeout`, `transport-error`, or `http-<code>`.',
example: 'ok',
},
sidecar: {
type: 'object',
nullable: true,
description: 'The sidecars own health document, or the mismatch detail on a protocol disagreement.',
properties: {
status: { type: 'string', example: 'ok' },
protocol: { type: 'integer', example: 1 },
plugin_connected: { type: 'boolean', example: true },
database: { type: 'string', example: 'ok' },
uptime: { type: 'string', example: '3h 2m' },
last_event: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
},
},
}

167
server/test/_fakes.js Normal file
View File

@@ -0,0 +1,167 @@
// ── Test doubles for what core hands the module ───────────────────────────
//
// Your server half is testable WITHOUT core, and that is not a convenience — it
// is the contract holding. Everything a module may touch arrives on `ctx`
// (MODULE_API.md §2.3), so a `ctx` this file can build is a complete statement of
// what your module depends on. **If a test ever needs something that is not here,
// either your module reached past the boundary or §2.3 needs a new member.** Both
// are worth stopping for.
//
// The fake mirrors §2.3 member for member — including the freezing, so a module
// that assigns to `ctx.something` fails here the way it would in core.
//
// This file lives under `test/`, which `checkImports.js` treats as not-shipped —
// which is why it may `require('express')` when the module's own routers may not.
// It builds a REAL express Router on purpose: a fake Router would only ever test
// the fake.
const express = require('express')
const expressValidator = require('express-validator')
/** Records every call, so a test can assert what the module asked for. */
function spy(returns) {
const fn = (...args) => {
fn.calls.push(args)
return typeof returns === 'function' ? returns(...args) : returns
}
fn.calls = []
return fn
}
function fakeLog() {
return { error: spy(), warn: spy(), info: spy(), debug: spy() }
}
function fakeCtx(overrides = {}) {
// `freeze: false` is a test seam for a suite that wants to adjust the ctx it
// installed. Core always freezes; the unfrozen variant is never a claim about
// what a module is handed in production.
const { freeze = true, ...rest } = overrides
const logs = []
const ctx = {
moduleId: 'rust',
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
express,
// The REAL express-validator, for the same reason express is real: the admin
// router builds its validation chains at file scope, so `{}` here is not
// something that file can even be required with.
validator: expressValidator,
db: { query: spy(Promise.resolve([])), pool: {} },
log: (namespace) => {
const log = fakeLog()
logs.push({ namespace, log })
return log
},
auth: { getUserFromRequest: spy(null) },
// One user by id. Null by default — an anonymous suite resolves nobody —
// and a test that needs a viewer installs its own.
users: { getById: spy(Promise.resolve(null)) },
// The engagement seam (§2.3). One method, recording, because that is the
// whole of what a module may do with it: fire a declared event and stop.
// Core's own emit is fire-and-forget and returns nothing, so this does too —
// a fake that returned a receipt would invite a module to wait on one.
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
// `ctx`, because an action is called BY core and is handed what it needs in
// the envelope. Only the module knows when the game restarted, so only the
// module can ask for the sweep.
events: { emit: spy(undefined), reconcile: spy(undefined) },
// Teams (§2.3, 1.6.0). Push only — there is no reader, because a module
// ANSWERS questions about Teams rather than asking them. `publish` and
// `activity.push` resolve like core's; `reconcile` returns nothing, because
// core's returns at once and a fake that returned a promise would invite a
// module to wait on a sweep it does not own.
teams: {
publish: spy(Promise.resolve()),
reconcile: spy(undefined),
activity: { push: spy(Promise.resolve(0)) },
},
// A REVERSIBLE fake, not a recording one. Core's box is AES-256-GCM keyed by
// the deployment's SECRET_ENC_KEY; what a test needs from it is that
// `decrypt(encrypt(x)) === x`, because the bug this module could have is a
// token stored under one shape and read under another. A spy returning a
// constant would pass while proving nothing, and the tag makes an accidental
// plaintext leak visible in an assertion.
secretBox: {
encrypt: (s) => `enc:${s}`,
decrypt: (s) => {
if (typeof s !== 'string' || !s.startsWith('enc:')) throw new Error('not encrypted by this box')
return s.slice(4)
},
},
activity: { log: spy(Promise.resolve()) },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
// The factory returns a pass-through rather than a real limiter: a test
// that tripped a rate limit would be a test whose result depended on how
// many times the suite had run.
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
accountChangeLimiter: (req, res, next) => next(),
},
site: { baseUrl: 'http://localhost:5173' },
...rest,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
// by the loop below and every `log.info` would throw on push. Keeping it out of
// the enumeration also makes the fake more faithful: a module iterating `ctx`
// sees §2.3's members and nothing a test put there.
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
if (!freeze) return ctx
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
return Object.freeze(ctx)
}
/**
* The registration api, recording rather than mounting.
*
* Copies core's `once()` rule (§2.4: "calling twice is an error"), so a module
* that registers the same thing twice fails in its own suite rather than first on
* an operator's install.
*/
function fakeApi() {
const record = {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null,
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
called.add(name)
}
const api = {
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook },
// `once` here is not the general rule restated — it is a DIFFERENT rule that
// happens to look the same. The others may not be called twice by ONE module;
// this one holds a single value across the whole deployment, so a second
// module registering a provider collides with the first. A fake cannot see
// the second module, and asserting the half it can see is still worth doing.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
// The event contract (1.10.0). `once` on all four: a batch is a module's
// COMPLETE statement about what it declares, so a second call is a module
// changing its mind halfway through `register()` rather than adding to it.
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}
api.record = record
return api
}
module.exports = { fakeCtx, fakeApi, spy }

View File

@@ -0,0 +1,160 @@
// ── The boundary, asserted ────────────────────────────────────────────────
//
// `catalogue.js` is the only thing standing between a frame carrying an IP
// address and a public page, so it gets a suite of its own rather than being
// covered incidentally by a route test.
//
// The most valuable test here is the last one: it holds the classification
// against the specification in `docs/rust-link/PROTOCOL.md` §8.4. Without it the
// two drift the first time somebody adds a kind to the protocol, and the drift
// is silent in the direction that matters — a new kind is simply never served,
// until the day somebody "fixes" that by adding it to the wrong list.
const test = require('node:test')
const assert = require('node:assert')
const catalogue = require('../catalogue')
test('an unknown kind is not public — the default is deny', () => {
assert.equal(catalogue.isPublic('player.death'), true)
assert.equal(catalogue.isPublic('something.new'), false)
assert.equal(catalogue.isPublic(''), false)
assert.equal(catalogue.isPublic(undefined), false)
// The shape of the mistake this prevents: a kind a LATER protocol adds, which
// this build ingests happily and would publish on the day it first arrived if
// the filter were a deny list.
assert.equal(catalogue.isKnown('player.location'), false)
assert.equal(catalogue.isPublic('player.location'), false)
})
test('nothing carrying an IP address, a report or an identity is public', () => {
for (const kind of [
'player.login.attempt',
'player.approved',
'player.banned',
'player.unbanned',
'player.reported',
'entity.destroyed',
// Protocol 3. A link request on a public killfeed would tell everyone which
// Steam id is about to become a named website account, and an unlink would
// say when somebody stopped being one.
'account.link.requested',
'account.unlinked',
]) {
assert.equal(catalogue.isPublic(kind), false, `${kind} must not be public`)
assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`)
}
})
test('a viewer with no kinds asked for gets the allowlist, never everything', () => {
const asPublic = catalogue.kindsFor({})
const asAdmin = catalogue.kindsFor({ admin: true })
// The public view with nothing said about presence is the kinds that name
// nobody — a wipe, a start, a shutdown.
assert.deepEqual(
asPublic,
catalogue.PUBLIC_KINDS.filter((k) => !catalogue.PRESENCE_KINDS.includes(k)),
)
assert.equal(asAdmin.length, catalogue.ALL_KINDS.length)
// The property that makes the route safe by construction: there is no argument
// a caller can omit that turns the filter off.
assert.ok(asPublic.length > 0)
assert.ok(!asPublic.includes('player.banned'))
})
test('a kind a viewer may not see is dropped, not refused', () => {
const asked = catalogue.kindsFor({ presence: true, requested: ['player.death', 'player.banned'] })
assert.deepEqual(asked, ['player.death'])
// Without the presence audience a death is dropped too.
assert.deepEqual(catalogue.kindsFor({ requested: ['player.death', 'server.wipe'] }), ['server.wipe'])
// Asking for only forbidden kinds answers with nothing to select, which the
// model turns into an empty list — the events are, as far as this viewer is
// concerned, not there.
assert.deepEqual(catalogue.kindsFor({ requested: ['player.banned'] }), [])
// And an admin gets what they asked for.
assert.deepEqual(catalogue.kindsFor({ admin: true, requested: ['player.banned'] }), [
'player.banned',
])
})
test('every kind is classified exactly once', () => {
const seen = new Set()
for (const kind of catalogue.ALL_KINDS) {
assert.ok(!seen.has(kind), `${kind} appears in both lists`)
seen.add(kind)
}
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
test('the classification covers exactly the kinds the protocol defines, through protocol 6', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
//
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
const PROTOCOL_4 = [
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
'entity.destroyed',
'player.reported',
'player.banned',
'player.unbanned',
'player.login.attempt',
'player.approved',
'server.wipe',
'server.initialized',
'server.shutdown',
'account.link.requested',
'account.unlinked',
'perm.drift',
// Protocol 6 (§12). Clan membership is members-only (D49), so every one of
// these is staff-class here and reaches members through core's Team feed.
'clan.created',
'clan.disbanded',
'clan.member.added',
'clan.member.left',
'clan.member.kicked',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
for (const kind of PROTOCOL_4.filter((k) => k.startsWith('clan.'))) {
assert.equal(catalogue.isPublic(kind), false, `${kind} is members-only and must not be public`)
}
})
test('every kind that names a player who was on is behind the presence setting', () => {
// The org lead's rule (2026-09-22): nothing tells who is online by default.
// Each of these says a named player was on the server at a given moment.
for (const kind of [
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
]) {
assert.ok(catalogue.isPresence(kind), `${kind} must be gated as presence`)
assert.ok(!catalogue.kindsFor({}).includes(kind), `${kind} must not reach a default public view`)
assert.ok(catalogue.kindsFor({ presence: true }).includes(kind))
}
// A presence kind is a subset of the public ones, never a staff kind widened.
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
// And what is left names nobody.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['server.initialized', 'server.shutdown', 'server.wipe'])
})

View File

@@ -0,0 +1,273 @@
// The bundle check, checked.
//
// `scripts/checkBundle.js` exists because of a failure this module has not had
// and does not intend to: Module-uo's v1.0.0 shipped without `server/commands/`
// and died at the register stage on the operator's box. A check written in
// response to one bug is worth exactly as much as its coverage of that bug, so
// the first two tests below are that bug, in both modes — a list that has stopped
// covering what the entry point reaches, and a tarball with the file missing from
// it — and the third pair is this module's own version of it, a runtime
// dependency declared and not packed.
//
// **Every fixture is a template literal, and that is load-bearing** — the same
// reason checkImports.test.js gives. `scripts/checkImports.js` scans this
// directory too, so an ordinary quoted string holding a relative require would
// make this file fail that check. Templates are blanked by the stripper.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
reachable,
resolveFile,
checkDeclaration,
checkBundle,
declaredServerPaths,
runtimeDependencies,
} = require('../scripts/checkBundle')
/**
* Write a throwaway module tree: `files` under server/, `bundle` as its
* ci/bundle.json, `pkg` as its server/package.json. Returns the module root.
*/
function fixture(files, bundle = { server: ['index.js'] }, pkg = null) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-rust-bundle-'))
for (const [name, source] of Object.entries(files)) {
const file = path.join(root, 'server', name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, source)
}
fs.mkdirSync(path.join(root, 'ci'), { recursive: true })
fs.writeFileSync(path.join(root, 'ci', 'bundle.json'), JSON.stringify(bundle))
if (pkg) {
fs.mkdirSync(path.join(root, 'server'), { recursive: true })
fs.writeFileSync(path.join(root, 'server', 'package.json'), JSON.stringify(pkg))
}
return root
}
const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true })
// ── The regression this script was written for ─────────────────────────────
test('--check catches a directory the include list has stopped covering', () => {
const root = fixture(
{
'index.js': `const r = require('./router/public/rust.router')`,
'router/public/rust.router.js': `module.exports = {}`,
},
{ server: ['index.js'] }, // `router` missing — exactly Module-uo's v1.0.0
)
try {
const { uncovered } = checkDeclaration(root)
assert.strictEqual(uncovered.size, 1)
assert.ok(uncovered.has('server/router'))
} finally {
cleanup(root)
}
})
test('--bundle catches the file missing from an assembled tarball', () => {
const root = fixture({ 'index.js': `require('./router/public/rust.router')` })
try {
const { missing } = checkBundle(root)
assert.strictEqual(missing.length, 1)
assert.strictEqual(missing[0].specifier, './router/public/rust.router')
} finally {
cleanup(root)
}
})
// ── This module's own version of that failure ──────────────────────────────
//
// The release packs no node_modules because the shipped half declares no
// dependencies (org lead, phase 2). The value of that decision is entirely in
// the day it stops being true being a LOUD day, so both modes ask.
test('--check reports a runtime dependency the release would not pack', () => {
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
name: 'x',
dependencies: { ws: '^8.21.0' },
})
try {
assert.deepStrictEqual(checkDeclaration(root).dependencies, ['ws'])
} finally {
cleanup(root)
}
})
test('--bundle reports a dependency the assembled bundle declares and does not carry', () => {
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
name: 'x',
dependencies: { ws: '^8.21.0' },
})
try {
assert.deepStrictEqual(checkBundle(root).dependencies, ['ws'])
} finally {
cleanup(root)
}
})
test('devDependencies are not runtime dependencies', () => {
// express, express-validator and swagger-autogen are all here and none of them
// ships: the shipped half is handed express on `ctx` (§2.3). A check that
// confused the two would fail on a correct repo, which is the one way to make
// everyone stop reading it.
const root = fixture({ 'index.js': `module.exports = 1` }, { server: ['index.js'] }, {
name: 'x',
devDependencies: { express: '^4.19.2' },
})
try {
assert.deepStrictEqual(runtimeDependencies(root), [])
} finally {
cleanup(root)
}
})
// ── It has to reach requires that are not at the top level ─────────────────
test('follows requires written inside a function', () => {
// index.js requires inside `register()` because require order is load-bearing:
// `core.init(ctx)` has to run before anything under router/ is required. A
// check that only saw file-scope requires would miss every router this module
// has.
const root = fixture(
{
'index.js': `module.exports = function register(ctx) { const r = require('./router/a') }`,
'router/a.js': `module.exports = {}`,
},
{ server: ['index.js', 'router'] },
)
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
assert.strictEqual(checkBundle(root).missing.length, 0)
} finally {
cleanup(root)
}
})
test('follows requires transitively, not just one hop', () => {
const root = fixture(
{
'index.js': `require('./a')`,
'a.js': `require('./b')`,
'b.js': `require('./deep/c')`,
'deep/c.js': `module.exports = {}`,
},
{ server: ['index.js', 'a.js', 'b.js'] }, // `deep` missing
)
try {
assert.ok(checkDeclaration(root).uncovered.has('server/deep'))
} finally {
cleanup(root)
}
})
// ── Resolution has to match Node's, or it invents failures ─────────────────
test('resolves a directory to its index.js', () => {
const root = fixture(
{ 'index.js': `require('./boot')`, 'boot/index.js': `module.exports = {}` },
{ server: ['index.js', 'boot'] },
)
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('resolves a .json dependency, and does not try to parse it for requires', () => {
// server/index.js's last line requires ../module.json, which is why this case
// is not hypothetical and why `generated` is in the declared list at all.
const root = fixture(
{ 'index.js': `require('./data/atlas.json')`, 'data/atlas.json': `{"a":1}` },
{ server: ['index.js', 'data'] },
)
try {
const { uncovered, missing } = checkDeclaration(root)
assert.strictEqual(missing.length, 0)
assert.strictEqual(uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('survives a require cycle', () => {
const root = fixture(
{ 'index.js': `require('./a')`, 'a.js': `require('./index')` },
{ server: ['index.js', 'a.js'] },
)
try {
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
} finally {
cleanup(root)
}
})
test('a specifier that resolves to nothing is reported, not thrown', () => {
const root = fixture({ 'index.js': `require('./gone')` })
try {
const { missing } = checkDeclaration(root)
assert.strictEqual(missing.length, 1)
assert.strictEqual(missing[0].specifier, './gone')
} finally {
cleanup(root)
}
})
test('prose describing a require is not a require', () => {
// The failure mode checkImports.js hit the first time it ran: index.js's own
// header explains why it must never require express, and comments in this repo
// name module paths constantly.
const root = fixture(
{ 'index.js': `// this file used to require('./router/gone')\nmodule.exports = 1` },
{ server: ['index.js'] },
)
try {
assert.strictEqual(checkDeclaration(root).missing.length, 0)
} finally {
cleanup(root)
}
})
test("node_modules inside a bundle is npm's business, not this check's", () => {
// Nothing ships one today. The skip stays so that the day a dependency does
// arrive, this is not also the thing that breaks.
const root = fixture({
'index.js': `module.exports = 1`,
'node_modules/ws/index.js': `require('./lib/that-npm-owns')`,
})
try {
assert.strictEqual(checkBundle(root).missing.length, 0)
} finally {
cleanup(root)
}
})
// ── And the real repo, which is the check that actually gates a release ────
test('the real ci/bundle.json covers everything the real entry point reaches', () => {
const { uncovered, missing, reached, dependencies } = checkDeclaration()
assert.deepStrictEqual([...uncovered.keys()], [])
assert.deepStrictEqual(missing, [])
assert.deepStrictEqual(dependencies, [])
assert.ok(reached > 1, 'the walk should reach more than the entry point itself')
})
test('every path ci/bundle.json declares exists', () => {
// A list naming a path that has moved packs nothing and says nothing — `cp` in
// the release would fail, but only after the tag had been pushed.
for (const p of declaredServerPaths()) {
assert.ok(fs.existsSync(p), `ci/bundle.json names ${p}, which does not exist`)
}
})
test('the entry point is reachable from the declared list', () => {
const entry = path.resolve(__dirname, '..', 'index.js')
assert.ok(reachable(entry).files.includes(entry))
assert.ok(resolveFile(path.dirname(entry), './core'))
})

View File

@@ -0,0 +1,149 @@
// The boundary check, checked.
//
// `scripts/checkImports.js` is the acceptance test for the whole module contract
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
// nobody knows the state of. These point it at fixtures that break each rule and
// assert it says so — and at prose that merely *describes* breaking them, which
// is what it got wrong the first time it was run.
//
// **Every fixture is a template literal, and that is load-bearing.** The scanner
// reads the files in this directory too, so an ordinary quoted string holding
// `require('../../x')` would make this file fail the very check it is testing.
// Templates are blanked by the stripper for exactly this class of text: source
// being composed as data is not source being imported.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
/** Write `files` into a throwaway module tree and scan it. */
function scanFixture(files, { dev = new Set() } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-tpl-'))
const src = path.join(root, 'server')
for (const [name, source] of Object.entries(files)) {
const file = path.join(src, name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, source)
}
try {
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
}
test('the real server half is clean', () => {
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
})
test('catches a relative path that escapes the module root', () => {
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
assert.strictEqual(found.length, 1)
assert.strictEqual(found[0].why, 'escapes the module root')
})
test('allows a relative path that stays inside it, however deep', () => {
assert.deepStrictEqual(
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
[],
)
})
test('catches an absolute path', () => {
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
assert.strictEqual(found[0].why, 'absolute path')
})
test('catches a bare specifier in shipped code, even a devDependency', () => {
// The rule that makes the boundary real: express arrives on ctx. A shipped
// file requiring it would fail on a real install, because a module lives
// outside core's server/ and never reaches core's node_modules.
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
assert.strictEqual(found.length, 1)
assert.match(found[0].why, /should this come from ctx/)
})
test('allows a devDependency in test code, which never runs inside core', () => {
assert.deepStrictEqual(
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
[],
)
})
test('allows node builtins anywhere, with or without the node: prefix', () => {
assert.deepStrictEqual(
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
[],
)
})
test('allows node:test, which older Node versions omit from builtinModules', () => {
// The first CI run failed on exactly this and on nothing else: `builtinModules`
// omits `test` on Node 20 and includes it on Node 24, so every test file in
// this suite was reported as breaking the module boundary. The check asks
// Node (`isBuiltin`) rather than rebuilding the list, and treats the `node:`
// prefix as sufficient on its own — a prefixed specifier can never resolve to
// a package, whatever the running version enumerates.
assert.deepStrictEqual(
scanFixture({ 'a.js': `require('node:test'); require('node:test/reporters')` }),
[],
)
})
test('catches ESM and dynamic forms, not only require()', () => {
const found = scanFixture({
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
})
assert.strictEqual(found.length, 2)
})
test('ignores a violation that is only DESCRIBED in a comment', () => {
// The first run of this check failed on its own documentation, and on
// index.js's comment explaining why the module must never require('express').
// Prose about the rule must not trip the rule.
assert.deepStrictEqual(
scanFixture({
'a.js': [
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
`/* Nor import express from "express": core hands it over on ctx. */`,
`const path = require('path')`,
].join('\n'),
}),
[],
)
})
test('ignores a specifier-shaped string inside a template literal', () => {
assert.deepStrictEqual(
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
[],
)
})
test('a comment opener inside a string does not swallow the rest of the file', () => {
// The reason this is a character walk and not a regexp: a URL in a string
// contains `//`, and treating that as a comment would blank everything after
// it — turning the check into one that silently passes.
const found = scanFixture({
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
})
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
})
test('a quote inside a comment does not swallow the rest of the file', () => {
const found = scanFixture({
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
})
assert.strictEqual(found.length, 1)
})
test('stripping preserves line numbers', () => {
// Blanked rather than removed, so anything that later reports a line still
// reports the right one.
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
})

552
server/test/clans.test.js Normal file
View File

@@ -0,0 +1,552 @@
// ── First-party clans → core's Teams (phase 9) ─────────────────────────────
//
// The properties this suite holds, each with a failure behind it:
//
// • a clan's identity carries its creation time (D52), so a reset clan
// database cannot hand an old clan's Team to a new one;
// • only a COMPLETE board may say a clan is 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 learned from the board, diffed (D54);
// • `getTeams` is complete only when EVERY server vouches (D53), and refuses
// rather than answering empty when none does;
// • a roster is shown to the clan's own members and staff by default (D48),
// re-read from the users row, and a failure withholds it;
// • every feed item is members-only (D49) and carries a dedupe key core will
// not truncate into a collision.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, spy } = require('./_fakes')
const SERVER = 'main'
const T0 = 1790142840000
function member(steamId, rank = 2, extra = {}) {
return { steamId, rank, role: rank === 1 ? 'Leader' : 'Member', joinedMs: T0, name: `P${steamId.slice(-2)}`, ...extra }
}
function clanRow(clanId, createdMs, members, extra = {}) {
return { clanId, createdMs, name: `Clan ${clanId}`, color: '#3FA9F5', score: 10, maxMembers: 100, members, ...extra }
}
/**
* The model and provider over an in-memory store, with a chosen viewer row.
*
* The store is small enough to reason about: clans and members by external id,
* and one board record per server. Every `clans.db` function the code under test
* calls is replaced; anything else it reached for would throw on the fake ctx.
*/
function setup({ users = {}, rosterSetting = null, servers = [{ id: SERVER }] } = {}) {
require('../core')._reset()
const ctx = fakeCtx({
users: { getById: async (id) => users[id] || null },
})
require('../core').init(ctx)
const db = require('../model/clans/clans.db')
const visibilityDb = require('../model/visibility/visibility.db')
const serversModel = require('../model/servers/servers.model')
const store = { clans: new Map(), members: new Map(), boards: new Map(), links: new Map(), online: new Set(), names: [] }
const originals = { db: { ...db }, visibilityDb: { ...visibilityDb }, servers: { ...serversModel } }
db.getBoard = async (serverId) => store.boards.get(serverId) || null
db.listBoards = async () =>
servers.map((s) => ({ serverId: s.id, serverName: s.id.toUpperCase(), ...(store.boards.get(s.id) || {}) }))
db.putBoard = async (b) => {
const prev = store.boards.get(b.serverId) || {}
store.boards.set(b.serverId, {
serverId: b.serverId,
boardT: b.boardT,
seenAt: b.advanced ? new Date() : prev.seenAt || null,
enabled: b.enabled ? 1 : 0,
supported: b.supported ? 1 : 0,
truncated: b.truncated ? 1 : 0,
backend: b.backend,
reason: b.reason,
umodClans: b.umodClans ? 1 : 0,
clanCount: b.clanCount,
})
}
db.listClansForServer = async (serverId) => [...store.clans.values()].filter((c) => c.serverId === serverId)
db.listMembersForServer = async (serverId) => {
const out = []
for (const c of store.clans.values()) {
if (c.serverId !== serverId || c.goneAt) continue
for (const m of store.members.get(c.externalId) || []) out.push({ externalId: c.externalId, ...m })
}
return out
}
db.upsertClan = async (c) => {
const prev = store.clans.get(c.externalId)
store.clans.set(c.externalId, { ...prev, ...c, members: undefined, goneAt: null })
}
db.replaceMembers = spy(async (externalId, members) => {
store.members.set(externalId, members.map((m) => ({ ...m })))
})
db.markGone = async (ids) => {
for (const id of ids) {
const c = store.clans.get(id)
if (c && !c.goneAt) c.goneAt = new Date()
store.members.delete(id)
}
}
db.findClan = async (id) => {
const c = store.clans.get(id)
return c ? { ...c, serverName: c.serverId.toUpperCase() } : null
}
db.findByGameId = async (serverId, clanId) => {
const hits = [...store.clans.values()]
.filter((c) => c.serverId === serverId && c.clanId === clanId)
.sort((a, b) => b.createdMs - a.createdMs)
return hits[0] ? { externalId: hits[0].externalId, name: hits[0].name } : null
}
db.listActiveClans = async () =>
[...store.clans.values()].filter((c) => !c.goneAt).map((c) => ({ ...c, serverName: c.serverId.toUpperCase() }))
db.listPublicForServer = async (serverId) =>
[...store.clans.values()].filter((c) => c.serverId === serverId && !c.goneAt)
db.listMembers = async (externalId) => {
const c = store.clans.get(externalId)
return (store.members.get(externalId) || []).map((m) => ({
...m,
userId: store.links.get(m.steamId) || null,
online: c && store.online.has(m.steamId) ? 1 : 0,
}))
}
db.userIsMember = async (externalId, userId) =>
(store.members.get(externalId) || []).some((m) => store.links.get(m.steamId) === userId)
db.recentClanEvents = async () => store.recent || []
db.rememberName = async (steamId, name) => store.names.push({ steamId, name })
visibilityDb.getSetting = async (key) => (key === 'clans.roster.audience' ? rosterSetting : null)
serversModel.listForPolling = async () => servers
const clans = require('../model/clans/clans.model')
const provider = require('../model/clans/teamProvider')
return {
ctx,
store,
clans,
provider,
restore: () => {
Object.assign(db, originals.db)
Object.assign(visibilityDb, originals.visibilityDb)
Object.assign(serversModel, originals.servers)
},
}
}
const board = (clans, extra = {}) => ({ kind: 'clans', type: 'snapshot', t: T0, supported: true, truncated: false, enabled: true, clans, ...extra })
// ── Identity ───────────────────────────────────────────────────────────────
test('a clan is keyed on server, game id AND creation time (D52)', () => {
const { clans, restore } = setup()
try {
const a = clans.normaliseClan(SERVER, clanRow(1, T0, [member('76561198000000001', 1)]))
const b = clans.normaliseClan(SERVER, clanRow(1, T0 + 5000, [member('76561198000000001', 1)]))
// Same game id, different clan: a reset database re-used id 1.
assert.notStrictEqual(a.externalId, b.externalId)
assert.strictEqual(a.externalId, `main:1:${T0}`)
// No id, no creation time, or no name: there is nothing to key it on.
assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, name: 'x' }), null)
assert.strictEqual(clans.normaliseClan(SERVER, { createdMs: T0, name: 'x' }), null)
assert.strictEqual(clans.normaliseClan(SERVER, { clanId: 1, createdMs: T0 }), null)
// A colour ends up in a style, so anything that is not #rrggbb is dropped.
assert.strictEqual(clans.normaliseClan(SERVER, clanRow(2, T0, [], { color: 'red;background:url(x)' })).color, null)
// A member whose Steam id is not one is dropped, not the clan.
const partial = clans.normaliseClan(SERVER, clanRow(3, T0, [member('7656'), { steamId: 'robert' }]))
assert.deepStrictEqual(partial.members.map((m) => m.steamId), ['7656'])
} finally {
restore()
}
})
// ── The board ──────────────────────────────────────────────────────────────
test('a first board stores its clans and asks core to reconcile', async () => {
const { ctx, store, clans, restore } = setup()
try {
const result = await clans.applyBoard(SERVER, board([
clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002')]),
]))
assert.strictEqual(result.applied, true)
assert.strictEqual(result.created, 1)
assert.strictEqual(store.clans.size, 1)
assert.strictEqual(store.members.get(`main:1:${T0}`).length, 2)
assert.strictEqual(ctx.teams.reconcile.calls.length, 1)
// Leaders of a brand-new clan reach core WITH the Team, not as a delta
// against a Team core does not hold yet.
assert.strictEqual(ctx.teams.publish.calls.length, 0)
} finally {
restore()
}
})
test('a board whose t has not moved is not applied again', async () => {
const { ctx, clans, store, restore } = setup()
try {
const b = board([clanRow(1, T0, [member('76561198000000001', 1)])])
await clans.applyBoard(SERVER, b)
store.members.clear()
const again = await clans.applyBoard(SERVER, b)
assert.strictEqual(again.applied, false)
assert.strictEqual(store.members.size, 0, 'nothing was rewritten')
assert.strictEqual(ctx.teams.reconcile.calls.length, 1)
} finally {
restore()
}
})
test('an unchanged roster is not rewritten when the board moves on', async () => {
const { clans, restore } = setup()
const db = require('../model/clans/clans.db')
try {
const members = [member('76561198000000001', 1)]
await clans.applyBoard(SERVER, board([clanRow(1, T0, members)]))
const writes = db.replaceMembers.calls.length
await clans.applyBoard(SERVER, board([clanRow(1, T0, members)], { t: T0 + 60000 }))
assert.strictEqual(db.replaceMembers.calls.length, writes)
} finally {
restore()
}
})
test('a complete board says a missing clan is gone; a truncated one does not (D55)', async () => {
const { clans, store, restore } = setup()
try {
await clans.applyBoard(SERVER, board([
clanRow(1, T0, [member('76561198000000001', 1)]),
clanRow(2, T0, [member('76561198000000002', 1)]),
]))
// At the ceiling: clan 2 is not listed, and that proves nothing.
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 60000, truncated: true }))
assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null)
// A row this build could not read counts the same way.
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)]), { name: 'broken' }], { t: T0 + 90000 }))
assert.strictEqual(store.clans.get(`main:2:${T0}`).goneAt, null)
// Complete, and still not listed: now it is gone.
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { t: T0 + 120000 }))
assert.ok(store.clans.get(`main:2:${T0}`).goneAt)
} finally {
restore()
}
})
test('a change of leader is published from the board diff (D54)', async () => {
const { ctx, clans, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002', 2)])]))
await clans.applyBoard(SERVER, board(
[clanRow(1, T0, [member('76561198000000001', 2), member('76561198000000002', 1)])],
{ t: T0 + 60000 },
))
const kinds = ctx.teams.publish.calls.map(([e]) => `${e.kind}:${e.memberKey}`).sort()
assert.deepStrictEqual(kinds, [
'team.leader.added:76561198000000002',
'team.leader.removed:76561198000000001',
])
} finally {
restore()
}
})
test('an unsupported board is recorded with its reason and touches no clan', async () => {
const { clans, store, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])]))
const result = await clans.applyBoard(SERVER, {
kind: 'clans', t: T0 + 60000, supported: false, reason: 'held by a NexusClanBackend', clans: [],
})
assert.strictEqual(result.applied, false)
assert.strictEqual(store.boards.get(SERVER).supported, 0)
assert.match(store.boards.get(SERVER).reason, /Nexus/)
assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null, 'an unreadable server says nothing about its clans')
// No board at all: a plugin older than protocol 6. Recorded, nothing touched.
await clans.applyBoard(SERVER, undefined)
assert.match(store.boards.get(SERVER).reason, /protocol 6/)
assert.strictEqual(store.clans.get(`main:1:${T0}`).goneAt, null)
} finally {
restore()
}
})
// ── The events ─────────────────────────────────────────────────────────────
test('each clan event is published as the Team kind core takes', async () => {
const { ctx, clans, restore } = setup()
try {
const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 }
await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' })
await clans.applyEvent(SERVER, { kind: 'clan.member.added', ...base, steamId: '76561198000000002', name: 'Bob' })
await clans.applyEvent(SERVER, { kind: 'clan.member.left', ...base, steamId: '76561198000000002', name: 'Bob' })
await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', bySteamId: '76561198000000001' })
assert.deepStrictEqual(ctx.teams.publish.calls.map(([e]) => [e.kind, e.memberKey]), [
['team.created', undefined],
['team.member.added', '76561198000000002'],
['team.member.removed', '76561198000000002'],
['team.member.removed', '76561198000000003'],
])
for (const [e] of ctx.teams.publish.calls) assert.strictEqual(e.externalId, `main:1:${T0}`)
} finally {
restore()
}
})
test('every feed item is members-only, and its dedupe key fits cores 40 characters', async () => {
const { ctx, clans, restore } = setup()
try {
const base = { clanId: 1, createdMs: T0, clanName: 'Clan 1', t: T0 + 1 }
await clans.applyEvent(SERVER, { kind: 'clan.created', ...base, steamId: '76561198000000001', name: 'Ann' })
await clans.applyEvent(SERVER, { kind: 'clan.member.kicked', ...base, steamId: '76561198000000003', name: 'Cy', byName: 'Ann', bySteamId: '76561198000000001' })
await clans.applyEvent(SERVER, { kind: 'clan.disbanded', ...base, steamId: '76561198000000001' })
const items = ctx.teams.activity.push.calls.map(([batch]) => batch[0])
// D49: founded and removed made lines; the disband did not.
assert.deepStrictEqual(items.map((i) => i.kind), ['rust.clan.founded', 'rust.clan.removed'])
assert.strictEqual(items[0].summary, 'Ann founded the clan.')
assert.strictEqual(items[1].summary, 'Cy was removed from the clan by Ann.')
assert.strictEqual(items[1].actorMemberKey, '76561198000000001', 'the actor of a kick is the kicker')
for (const item of items) {
assert.strictEqual(item.visibility, 'members')
// Core clamps a dedupe key to 40 characters. A readable one would be cut
// short into collisions; a sha1 is exactly 40.
assert.match(item.dedupeKey, /^[0-9a-f]{40}$/)
assert.strictEqual(typeof item.occurredAt, 'number', 'core reads occurredAt as epoch ms')
}
assert.notStrictEqual(items[0].dedupeKey, items[1].dedupeKey)
} finally {
restore()
}
})
test('the same frame offered twice carries the same key, so a re-offer is a no-op', async () => {
const { ctx, clans, store, restore } = setup()
try {
const frame = { kind: 'clan.member.added', clanId: 1, createdMs: T0, t: T0 + 5, steamId: '76561198000000002', name: 'Bob' }
await clans.applyEvent(SERVER, frame)
store.recent = [{ id: 1, kind: frame.kind, t: frame.t, raw: JSON.stringify(frame) }]
const offered = await clans.reofferActivity(SERVER)
assert.strictEqual(offered, 1)
const [first, second] = ctx.teams.activity.push.calls.map(([batch]) => batch[0].dedupeKey)
assert.strictEqual(first, second)
} finally {
restore()
}
})
test('a join without a creation time is matched on the game id, newest clan first', async () => {
const { ctx, clans, restore } = setup()
try {
await clans.applyBoard(SERVER, board([
clanRow(1, T0, [member('76561198000000001', 1)]),
]))
const result = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 1, t: T0 + 1, steamId: '76561198000000009' })
assert.strictEqual(result.externalId, `main:1:${T0}`)
// A clan this module has never heard of is skipped, not guessed at.
const unknown = await clans.applyEvent(SERVER, { kind: 'clan.member.added', clanId: 77, t: T0 + 2, steamId: '76561198000000009' })
assert.strictEqual(unknown.applied, false)
assert.ok(ctx.teams.publish.calls.every(([e]) => e.externalId === `main:1:${T0}`))
} finally {
restore()
}
})
test('a disband marks the clan gone even when the board could not say so', async () => {
const { clans, store, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])], { truncated: true }))
await clans.applyEvent(SERVER, { kind: 'clan.disbanded', clanId: 1, createdMs: T0, t: T0 + 1, steamId: '76561198000000001' })
assert.ok(store.clans.get(`main:1:${T0}`).goneAt)
} finally {
restore()
}
})
// ── The provider ───────────────────────────────────────────────────────────
test('getTeams is complete only when every server vouches (D53)', async () => {
const both = [{ id: 'main' }, { id: 'pvp' }]
const { clans, provider, restore } = setup({ servers: both })
try {
// Neither server has a board: refuse, never "no teams".
const none = await provider.getTeams()
assert.strictEqual(none.ok, false)
// One current, one never heard from: partial, so core removes nothing.
await clans.applyBoard('main', board([clanRow(1, T0, [member('76561198000000001', 1)])]))
const partial = await provider.getTeams()
assert.strictEqual(partial.ok, true)
assert.strictEqual(partial.complete, false)
assert.deepStrictEqual(partial.teams.map((t) => t.externalId), [`main:1:${T0}`])
assert.strictEqual(partial.teams[0].meta.serverId, 'main')
// Both current: complete.
await clans.applyBoard('pvp', board([]))
assert.strictEqual((await provider.getTeams()).complete, true)
// One at the ceiling: partial again.
await clans.applyBoard('pvp', board([], { t: T0 + 60000, truncated: true }))
assert.strictEqual((await provider.getTeams()).complete, false)
} finally {
restore()
}
})
test('a board that stops advancing stops vouching', async () => {
const { store, clans, provider, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1)])]))
assert.strictEqual((await provider.getTeams()).ok, true)
store.boards.get(SERVER).seenAt = new Date(Date.now() - clans.FRESH_MS - 1000)
assert.strictEqual((await provider.getTeams()).ok, false)
assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false)
} finally {
restore()
}
})
test('getTeams refuses on a site with no Rust servers', async () => {
const { provider, restore } = setup({ servers: [] })
try {
const answer = await provider.getTeams()
assert.deepStrictEqual(answer.ok, false)
assert.match(answer.reason, /no Rust servers/)
} finally {
restore()
}
})
test('a roster names its members, its leaders, the linked account and who is on', async () => {
const { store, clans, provider, restore } = setup()
try {
await clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000002'), member('76561198000000003', null, { rank: null, role: null })])]))
store.links.set('76561198000000002', 42)
store.online.add('76561198000000001')
const roster = await provider.getTeamMembers(`main:1:${T0}`)
assert.strictEqual(roster.ok, true)
const byKey = Object.fromEntries(roster.members.map((m) => [m.memberKey, m]))
assert.strictEqual(byKey['76561198000000001'].leader, true)
assert.strictEqual(byKey['76561198000000001'].online, true)
assert.strictEqual(byKey['76561198000000002'].userId, 42)
// A rank the board could not match is not a leader.
assert.strictEqual(byKey['76561198000000003'].leader, false)
const leaders = await provider.getTeamLeaders(`main:1:${T0}`)
assert.deepStrictEqual(leaders, { ok: true, leaders: ['76561198000000001'] })
// A clan with a count but no stored rows is a read between two writes.
store.members.set(`main:1:${T0}`, [])
assert.strictEqual((await provider.getTeamMembers(`main:1:${T0}`)).ok, false)
} finally {
restore()
}
})
// ── Who may see a roster (D48) ─────────────────────────────────────────────
const USERS = {
1: { id: 1, role: 'player', status: 'active' }, // linked to a member
2: { id: 2, role: 'player', status: 'active' }, // not a member
3: { id: 3, role: 'moderator', status: 'active' },
4: { id: 4, role: 'player', status: 'banned' }, // linked to a member, banned
}
async function rosterFixture(options) {
const fx = setup({ users: USERS, ...options })
await fx.clans.applyBoard(SERVER, board([clanRow(1, T0, [member('76561198000000001', 1), member('76561198000000004')])]))
fx.store.links.set('76561198000000001', 1)
fx.store.links.set('76561198000000004', 4)
return fx
}
const keysFor = async (provider, viewer) =>
(await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }, { member_key: '76561198000000004' }], viewer)).members.length
test('by default a roster is for the clans own members and staff', async () => {
const { provider, restore } = await rosterFixture()
try {
assert.strictEqual(await keysFor(provider, null), 0, 'anonymous')
assert.strictEqual(await keysFor(provider, { userId: 2, role: 'player' }), 0, 'a stranger')
assert.strictEqual(await keysFor(provider, { userId: 1, role: 'player' }), 2, 'a member')
assert.strictEqual(await keysFor(provider, { userId: 3, role: 'moderator' }), 2, 'staff')
// The row, not the claim: a banned member sees nothing, and a claimed role
// the row does not hold grants nothing.
assert.strictEqual(await keysFor(provider, { userId: 4, role: 'player' }), 0, 'banned')
assert.strictEqual(await keysFor(provider, { userId: 2, role: 'admin' }), 0, 'a claim is not a role')
} finally {
restore()
}
})
test('the operator can widen it, and an unknown setting narrows back', async () => {
const signedIn = await rosterFixture({ rosterSetting: 'signed_in' })
try {
assert.strictEqual(await keysFor(signedIn.provider, { userId: 2, role: 'player' }), 2)
assert.strictEqual(await keysFor(signedIn.provider, null), 0)
} finally {
signedIn.restore()
}
const open = await rosterFixture({ rosterSetting: 'public' })
try {
assert.strictEqual(await keysFor(open.provider, null), 2)
} finally {
open.restore()
}
const typo = await rosterFixture({ rosterSetting: 'everyone' })
try {
assert.strictEqual(await keysFor(typo.provider, { userId: 2, role: 'player' }), 0)
} finally {
typo.restore()
}
})
test('a roster question that cannot be answered withholds the roster', async () => {
const { provider, restore } = await rosterFixture()
const visibilityDb = require('../model/visibility/visibility.db')
try {
visibilityDb.getSetting = async () => {
throw new Error('pool exhausted')
}
const answer = await provider.projectRoster(`main:1:${T0}`, [{ member_key: '76561198000000001' }], { userId: 3 })
// Core fails CLOSED on this one call: a refusal serves an empty roster.
assert.strictEqual(answer.ok, false)
} finally {
restore()
}
})
test('the clan page carries no Steam id and no account id, and no names below the audience', async () => {
const { clans, restore } = await rosterFixture()
try {
const outside = await clans.getForViewer(`main:1:${T0}`, null)
assert.strictEqual(outside.roster.visible, false)
assert.deepStrictEqual(outside.roster.members, [])
assert.strictEqual(outside.clan.memberCount, 2, 'the count is public (D58)')
const inside = await clans.getForViewer(`main:1:${T0}`, { userId: 1 })
assert.strictEqual(inside.roster.visible, true)
assert.strictEqual(inside.roster.members.length, 2)
for (const m of inside.roster.members) {
assert.ok(!('steamId' in m) && !('userId' in m), 'no identifier leaves on a roster row')
}
assert.strictEqual(await clans.getForViewer('main:99:1', null), null)
} finally {
restore()
}
})

437
server/test/config.test.js Normal file
View File

@@ -0,0 +1,437 @@
// ── Configuration from the site, above the editor ─────────────────────────
//
// `configEdit.test.js` covers the bytes. This covers the decisions made around
// them, and each of these is a way the feature could look fine and be wrong:
//
// • a save that never re-reads the host writes a browser's stale copy over
// somebody else's edit;
// • the raw tier is a whole document, so a locked key can change without
// anything resembling an edit to a field (D38);
// • a rollback is a round trip that WORKED, carrying bad news, and reporting
// it as a failure throws away the only diagnosis there is;
// • a refusal that is never recorded leaves the operator asking why a setting
// is not what they set, with nothing to read.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore(overrides = {}) {
const queries = []
require('../core')._reset()
require('../core').init(
fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') return Promise.resolve([])
return Promise.resolve({ affectedRows: 1, insertId: 1 })
},
pool: {},
},
...overrides,
}),
)
return queries
}
/** A response double that records what a controller decided. */
function fakeRes() {
const res = { statusCode: 200, body: null }
res.status = (code) => {
res.statusCode = code
return res
}
res.json = (body) => {
res.body = body
return res
}
return res
}
const FILE = `{
"Gather": { "Wood": 1.0 },
"Enabled": true
}`
const OWN = `{
"Host": "127.0.0.1",
"Port": 7799,
"QueueCap": 5000,
"ServerId": "main"
}`
/** Stubs the four calls this controller can make, and records them. */
function stubSidecar({ file = FILE, self = 'RunicGateway', write } = {}) {
const sidecar = require('../sidecarClient')
const calls = []
sidecar.configFile = async (server, asked) => {
calls.push(['read', asked])
const text = asked === 'RunicGateway.json' ? OWN : file
return {
ok: true,
status: 'ok',
data: { kind: 'config.file', path: asked, text, version: `v-${asked}`, bytes: text.length },
}
}
sidecar.configFiles = async () => {
calls.push(['list'])
return {
ok: true,
status: 'ok',
data: {
kind: 'config.catalogue',
root: '/home/container/oxide/config',
self,
files: [
{ path: 'ZoneManager.json', bytes: 40, editable: true, plugin: 'ZoneManager' },
{ path: 'RunicGateway.json', bytes: 90, editable: true, plugin: 'RunicGateway' },
{ path: 'Huge.json', bytes: 9e6, editable: false, reason: 'larger than this bridge will carry', plugin: 'Huge' },
],
plugins: [{ name: 'ZoneManager', title: 'Zone Manager', version: '3.1.14' }],
truncated: false,
},
}
}
sidecar.configWrite = async (server, body) => {
calls.push(['write', body])
return write || { ok: true, status: 'ok', data: { kind: 'config.report', ok: true, reloaded: true, files: [{ path: body.files[0].path, version: 'v-after' }] } }
}
return calls
}
/** The controller's own server lookup, satisfied without a database. */
function stubServer(row = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://x', sidecarTokenEnc: null, protocol: 5 }) {
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
serversDb.getServer = async () => row
servers.withToken = () => (row ? { id: row.id, baseUrl: row.sidecarBaseUrl, token: 't' } : null)
}
function controller() {
return require('../router/admin/config.controller')
}
test('the catalogue is grouped by plugin, and an unloaded one is marked rather than dropped', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().listFiles({ params: { serverId: 'main' }, query: {} }, res)
const zone = res.body.plugins.find((p) => p.plugin === 'ZoneManager')
const bridge = res.body.plugins.find((p) => p.plugin === 'RunicGateway')
const huge = res.body.plugins.find((p) => p.plugin === 'Huge')
assert.equal(zone.loaded, true)
assert.equal(zone.version, '3.1.14')
// Not loaded, still listed. A config that vanished from the page would read
// as "the bridge cannot see it", which is a much more alarming problem than
// the true one.
assert.equal(huge.loaded, false)
assert.equal(huge.files[0].editable, false)
assert.ok(huge.files[0].reason)
// The bridge's own config is named as such, because it is the one plugin that
// cannot be reloaded from here.
assert.equal(bridge.isBridge, true)
assert.equal(res.body.root, '/home/container/oxide/config')
})
test('a save re-reads the host and refuses a stale version with the current file', async () => {
withCore()
stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: { path: 'ZoneManager.json', version: 'v-stale', edits: [{ pointer: ['Enabled'], value: false }] },
},
res,
)
assert.equal(res.statusCode, 409)
// The current file comes back, so a person can merge their change rather than
// retype it from memory.
assert.equal(res.body.current.version, 'v-ZoneManager.json')
assert.match(res.body.message, /changed on the server/)
})
test('a form edit is spliced into what is on disk NOW, and sent as whole text', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'ZoneManager',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
const [, body] = calls.find((c) => c[0] === 'write')
assert.equal(body.files.length, 1)
assert.equal(body.reload, 'ZoneManager')
assert.equal(body.files[0].version, 'v-ZoneManager.json')
assert.match(body.files[0].text, /"Enabled": false/)
// The untouched float, which is the entire reason this path exists.
assert.match(body.files[0].text, /"Wood": 1\.0/)
assert.equal(res.statusCode, 200)
assert.equal(res.body.changed, true)
assert.equal(res.body.report.reloaded, true)
})
test('a save that changes nothing does not reach the game at all', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Enabled'], value: true }],
},
},
res,
)
assert.equal(res.body.changed, false)
// A write would have spent a reload, and a reload is the one part of this
// feature that can take a plugin down.
assert.equal(calls.some((c) => c[0] === 'write'), false)
})
test('the raw tier cannot change a locked key, even though it sends a whole document (D38)', async () => {
withCore()
const calls = stubSidecar()
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'RunicGateway.json',
version: 'v-RunicGateway.json',
text: OWN.replace('7799', '9999'),
},
},
res,
)
assert.equal(res.statusCode, 400)
assert.match(res.body.message, /Port/)
assert.equal(calls.some((c) => c[0] === 'write'), false)
// And the rest of our own config is still editable, which is the half of D38
// that is easy to lose.
const ok = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'RunicGateway.json',
version: 'v-RunicGateway.json',
text: OWN.replace('5000', '9000'),
},
},
ok,
)
assert.equal(ok.statusCode, 200)
assert.equal(ok.body.changed, true)
})
test('a rollback is a 200 carrying bad news, and the log line survives to the admin', async () => {
const queries = withCore()
stubSidecar({
write: {
ok: true,
status: 'ok',
data: {
kind: 'config.report',
ok: false,
reloaded: false,
rolledBack: true,
reason: "'ZoneManager' did not reload within 4s",
log: 'Error while compiling ZoneManager: expected , at line 14',
files: [{ path: 'ZoneManager.json', version: 'v-restored' }],
},
},
})
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'ZoneManager',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 200)
assert.equal(res.body.report.rolledBack, true)
assert.match(res.body.report.log, /line 14/)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
assert.ok(audit, 'a rollback must be recorded')
assert.ok(audit.params.includes('rolled-back'))
})
test('a refusal from the game is recorded too, with its own status', async () => {
const queries = withCore()
stubSidecar({
write: { ok: true, status: 'ok', data: { kind: 'config.error', reason: 'reload-self' } },
})
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
reload: 'RunicGateway',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 400)
assert.match(res.body.message, /cannot be reloaded from the website/)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
assert.ok(audit && audit.params.includes('refused'), 'a refusal is part of the audit trail')
})
test('a server that cannot be reached answers 503 with the reason, and records the attempt', async () => {
const queries = withCore()
stubSidecar({ write: { ok: false, status: 'http-503', data: null } })
stubServer()
const res = fakeRes()
await controller().writeFile(
{
params: { serverId: 'main' },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Enabled'], value: false }],
},
},
res,
)
assert.equal(res.statusCode, 503)
assert.match(res.body.message, /not connected/)
assert.ok(queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes')))
})
test('a path that could not have come from the host is refused before any round trip', async () => {
withCore()
const calls = stubSidecar()
stubServer()
for (const path of ['../oxide/data/oxide.users.data', '/etc/passwd', 'C:/x.json', 'notjson.txt', '']) {
const res = fakeRes()
await controller().readFile({ params: { serverId: 'main' }, query: { path } }, res)
assert.equal(res.statusCode, 400, `${path} should be refused`)
}
assert.equal(calls.length, 0, 'nothing malformed should cost a round trip')
// And the shapes a host really does list.
for (const path of ['Kits.json', 'Kits/kits.json', 'My Mod/sub dir/file.json']) {
assert.equal(require('../model/config/config.model').isPlausiblePath(path), true, path)
}
})
test('the audit trail records which fields changed, and never a credential', async () => {
const queries = withCore()
stubSidecar({ file: '{\n "Discord Webhook": "https://hooks/1",\n "Enabled": true\n}' })
stubServer()
await controller().writeFile(
{
params: { serverId: 'main' },
user: { id: 7 },
body: {
path: 'ZoneManager.json',
version: 'v-ZoneManager.json',
edits: [{ pointer: ['Discord Webhook'], value: 'https://hooks/2' }],
},
},
fakeRes(),
)
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
const changes = audit.params.find((p) => typeof p === 'string' && p.startsWith('['))
assert.match(changes, /Discord Webhook/)
assert.ok(!changes.includes('hooks/1'), 'the old credential must not be recorded')
assert.ok(!changes.includes('hooks/2'), 'the new credential must not be recorded')
assert.ok(audit.params.includes(7), 'the person who did it is recorded')
})
test('a file that is already broken on disk still opens, in the tier that can fix it', () => {
const model = require('../model/config/config.model')
const shaped = model.shapeFile(
{ path: 'Broken.json', text: '{ "a": }', version: 'v1', bytes: 8 },
{ self: 'RunicGateway' },
)
assert.equal(shaped.fields, null)
assert.ok(shaped.parseError, 'the reason it cannot be drawn is part of the answer')
assert.equal(shaped.text, '{ "a": }')
})
test('the bridges own file is recognised by the plugins name, not by a filename we matched', () => {
const model = require('../model/config/config.model')
assert.equal(model.isBridgeConfig('RunicGateway.json', 'RunicGateway'), true)
assert.equal(model.isBridgeConfig('RunicGateway/extra.json', 'RunicGateway'), true)
assert.equal(model.isBridgeConfig('ZoneManager.json', 'RunicGateway'), false)
// Renamed on the host: the lock follows the plugin, which is the only thing
// that knows what it is called.
assert.equal(model.isBridgeConfig('Bridge.json', 'Bridge'), true)
assert.deepEqual(model.lockedKeysFor('Bridge.json', 'Bridge'), model.LOCKED_KEYS)
// And a host that said nothing about itself locks nothing, rather than
// locking everything or guessing.
assert.deepEqual(model.lockedKeysFor('RunicGateway.json', null), [])
})

View File

@@ -0,0 +1,236 @@
// ── The editor that must not touch what it was not asked to ───────────────
//
// `configEdit.js` exists for one reason: a config file goes back to the game
// host byte-identical except where an admin deliberately changed something. So
// the suite is mostly about what does NOT change, and the first test is the one
// the whole design is for.
//
// It is worth being concrete about the failure being prevented. `Rate: 1.0` in
// an untouched field, read through `JSON.parse` and written back through
// `JSON.stringify`, becomes `Rate: 1`. Newtonsoft may coerce that into a
// `float` or may throw; if it throws, the plugin does not come back from its
// reload — and R6/R17 make four plugins required, so "ZoneManager is down" is
// also "event participation is down".
const test = require('node:test')
const assert = require('node:assert')
const configEdit = require('../configEdit')
/** A config with every shape that has ever caused trouble. */
const SAMPLE = `{
"Gather": {
"Wood": 1.0,
"Stone": 2.50,
"Sulfur": 3,
"Scale": 1e3
},
"Enabled": true,
"Message": "Welcome, {name}",
"Discord Webhook": "https://discord.com/api/webhooks/1/abc",
"Zones": ["a", "b"],
"Nothing": null,
"Empty": []
}`
test('an untouched float keeps its literal — the whole point of this file', () => {
const { text, changes } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: false }])
assert.equal(changes.length, 1)
assert.match(text, /"Wood": 1\.0/)
assert.match(text, /"Stone": 2\.50/)
assert.match(text, /"Scale": 1e3/)
assert.match(text, /"Enabled": false/)
// And the proof that the naive implementation would have failed this: the same
// document through parse/stringify loses all three.
const naive = JSON.stringify(JSON.parse(SAMPLE))
assert.match(naive, /"Wood":1,/)
assert.doesNotMatch(naive, /2\.50/)
assert.doesNotMatch(naive, /1e3/)
})
test('a number is written as the literal an admin typed, not as a Number', () => {
const { text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Wood'], raw: '2.0' }])
assert.match(text, /"Wood": 2\.0/)
// The same edit through a JavaScript number would have produced `2`, which is
// a different C# type at the far end.
assert.equal(String(2.0), '2')
})
test('everything else in the document is byte-identical', () => {
const { text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw: '4' }])
const before = SAMPLE.split('\n')
const after = text.split('\n')
assert.equal(before.length, after.length)
before.forEach((line, i) => {
if (line.includes('"Sulfur"')) return
assert.equal(after[i], line, `line ${i + 1} changed and should not have`)
})
})
test('a literal that is not a JSON number is refused', () => {
for (const raw of ['0x10', '', ' ', '1.', '.5', '01', 'NaN', 'Infinity', '1,0', '5; rm -rf /']) {
const { error, text } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw }])
assert.ok(error, `'${raw}' should be refused`)
assert.equal(text, undefined)
}
// And the ones that must keep working, because preserving them is the point.
for (const raw of ['1.0', '-2', '1e3', '1E-3', '0', '0.5', '123456789012345678']) {
const { error } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Sulfur'], raw }])
assert.equal(error, undefined, `'${raw}' should be accepted`)
}
})
test('the form cannot change a value KIND — that is the raw tier', () => {
assert.match(
configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: 'yes' }]).error,
/true or false/,
)
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Message'], value: 7 }]).error, /expected text/)
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Zones'], value: 'a' }]).error, /raw tier/)
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Nothing'], value: 1 }]).error, /raw tier/)
})
test('a string is escaped on the way in', () => {
const { text } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Message'], value: 'He said "hi"\nand left\\' },
])
assert.match(text, /"Message": "He said \\"hi\\"\\nand left\\\\"/)
// Still JSON, and still the same string coming back out.
assert.equal(JSON.parse(text).Message, 'He said "hi"\nand left\\')
})
test('a pointer that is not in the file is refused rather than created', () => {
assert.match(configEdit.applyEdits(SAMPLE, [{ pointer: ['Nope'], value: true }]).error, /not in this file/)
assert.match(
configEdit.applyEdits(SAMPLE, [{ pointer: ['Gather', 'Wood', 'Deeper'], raw: '1' }]).error,
/not in this file/,
)
})
test('an edit set that changes nothing writes nothing', () => {
const { text, changes } = configEdit.applyEdits(SAMPLE, [{ pointer: ['Enabled'], value: true }])
assert.equal(text, SAMPLE)
assert.deepEqual(changes, [])
})
test('two edits to the same field in one save are refused', () => {
const { error } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Gather', 'Wood'], raw: '1.0' },
{ pointer: ['Gather', 'Wood'], raw: '2.0' },
])
assert.match(error, /edited twice/)
})
test('several edits land together, and later offsets are not shifted by earlier ones', () => {
const { text, changes } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Gather', 'Wood'], raw: '10.0' },
{ pointer: ['Message'], value: 'much longer than it was before' },
{ pointer: ['Zones', 1], value: 'bb' },
])
assert.equal(changes.length, 3)
const parsed = JSON.parse(text)
assert.equal(parsed.Gather.Wood, 10)
assert.equal(parsed.Message, 'much longer than it was before')
assert.deepEqual(parsed.Zones, ['a', 'bb'])
assert.match(text, /"Wood": 10\.0/)
})
test('a locked key cannot be edited, and the refusal names it (D38)', () => {
const own = '{\n "Host": "127.0.0.1",\n "Port": 7799,\n "QueueCap": 5000\n}'
const locked = ['Host', 'Port', 'ServerId']
assert.match(configEdit.applyEdits(own, [{ pointer: ['Port'], raw: '1' }], { locked }).error, /Port/)
assert.match(
configEdit.applyEdits(own, [{ pointer: ['Host'], value: '10.0.0.5' }], { locked }).error,
/cannot be edited/,
)
// Everything else in the bridge's own config stays editable, which is the
// half of D38 that is easy to lose.
const { text } = configEdit.applyEdits(own, [{ pointer: ['QueueCap'], raw: '9000' }], { locked })
assert.match(text, /"QueueCap": 9000/)
})
test('a secret is flagged by WORD, not by substring', () => {
for (const key of ['ApiKey', 'Discord Webhook', 'steam_api_key', 'Token', 'Password', 'authToken']) {
assert.equal(configEdit.isSecretKey(key), true, `${key} should be a secret`)
}
// The false positives a substring match would produce, and they matter: a
// form that masks a third of every config teaches an operator to ignore the
// mask, which is worse than not masking.
for (const key of ['Monkey', 'Keybind', 'Passive Mode', 'Authority', 'Keycards Allowed']) {
assert.equal(configEdit.isSecretKey(key), false, `${key} should not be a secret`)
}
// A genuinely ambiguous one, resolved toward masking on purpose: a field
// called `Keys` is a credential often enough, and the cost of being wrong is
// a field an admin has to click to read rather than a credential on a page.
assert.equal(configEdit.isSecretKey('Keys'), true)
})
test("a secret's values never reach the audit trail, though the change is recorded", () => {
const { changes } = configEdit.applyEdits(SAMPLE, [
{ pointer: ['Discord Webhook'], value: 'https://discord.com/api/webhooks/2/def' },
])
assert.equal(changes.length, 1)
assert.equal(changes[0].path, 'Discord Webhook')
assert.equal(changes[0].from, '***')
assert.equal(changes[0].to, '***')
assert.equal(changes[0].secret, true)
})
test('the form description says which fields it cannot draw, and why', () => {
const fields = configEdit.describe(configEdit.scan(SAMPLE))
const by = (path) => fields.find((f) => f.path === path)
assert.equal(by('Gather.Wood').type, 'number')
assert.equal(by('Gather.Wood').raw, '1.0')
assert.equal(by('Enabled').type, 'boolean')
assert.equal(by('Zones[0]').value, 'a')
assert.equal(by('Discord Webhook').secret, true)
// The three things a value cannot tell us anything about.
assert.equal(by('Nothing').advanced, true)
assert.equal(by('Empty').advanced, true)
assert.ok(by('Nothing').reason)
assert.ok(by('Empty').reason)
})
test('a subtree past the depth limit is advanced-only rather than half-drawn', () => {
const deep = '{"a":{"b":{"c":{"d":{"e":{"f":{"g":1}}}}}}}'
const fields = configEdit.describe(configEdit.scan(deep), { maxDepth: 3 })
const past = fields.find((f) => f.path === 'a.b.c')
assert.equal(past.advanced, true)
assert.equal(fields.some((f) => f.path.startsWith('a.b.c.')), false)
})
test('a document that is not JSON is refused with a position', () => {
assert.throws(() => configEdit.scan('{"a": }'), /offset/)
assert.throws(() => configEdit.scan('{"a": 1,}'), /expected a key/)
assert.throws(() => configEdit.scan('{} trailing'), /trailing content/)
assert.throws(() => configEdit.scan('{"a": "unterminated'), /unterminated/)
const { error } = configEdit.applyEdits('{ not json', [{ pointer: ['a'], value: true }])
assert.match(error, /not valid JSON/)
})
test('escapes and unicode survive a scan of a document nobody edited', () => {
const text = '{"a":"tab\\there","b":"\\u00e9\\u0041","c":"slash\\/"}'
const root = configEdit.scan(text)
const values = Object.fromEntries(root.children.map((c) => [c.key, c.value]))
assert.deepEqual(values, JSON.parse(text))
})

179
server/test/entry.test.js Normal file
View File

@@ -0,0 +1,179 @@
// ── The registration handshake ────────────────────────────────────────────
//
// The one suite every module should have, whatever else it does. Core validates
// all of this at boot and refuses to mount a module that fails — so testing it
// here is the difference between finding out in half a second and finding out on
// an operator's install.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, fakeApi } = require('./_fakes')
const manifest = require('../../module.json')
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
function register(ctx = fakeCtx()) {
require('../core')._reset()
const api = fakeApi()
require('../index')(ctx, api)
return { api, ctx }
}
test('registers exactly the mounts module.json declares', () => {
const { api } = register()
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
// declared and never registered is as fatal as a route registered and never
// declared. Asserting against the manifest rather than against a literal is
// what keeps the test true after a prefix is added.
assert.deepStrictEqual(
Object.keys(api.record.routes).sort(),
Object.keys(manifest.mounts).sort(),
)
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
}
})
test('all three tiers are mounted (R14)', () => {
const { api } = register()
// Not the assertion above restated. That one says the manifest and the code
// agree; this one says WHICH answer they agree on, so that deleting a tier from
// both halves at once still fails. R14 puts this module on all three from the
// start precisely so that a later phase adding a player surface does not have
// to move an address clients are already calling.
assert.deepStrictEqual(Object.keys(api.record.routes).sort(), ['admin', 'player', 'public'])
for (const tier of ['admin', 'player', 'public']) {
assert.deepStrictEqual(Object.keys(api.record.routes[tier]), ['/rust'])
}
})
test('every registered mount is a real express router', () => {
const { api } = register()
for (const byPrefix of Object.values(api.record.routes)) {
for (const [prefix, router] of Object.entries(byPrefix)) {
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
assert.ok(router.stack, `${prefix} has no middleware stack`)
}
}
})
test('prefixes are one segment, lowercase, no parameters', () => {
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
// module that fails it does not mount at all.
for (const prefixes of Object.values(manifest.mounts)) {
for (const prefix of prefixes) {
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
}
}
})
test('registration touches no database and awaits nothing', () => {
const ctx = fakeCtx()
register(ctx)
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
// port in two build tools, so a query here would hang both — and the symptom is
// a build that never finishes rather than an error naming this module.
assert.deepStrictEqual(ctx.db.query.calls, [])
})
test('registers both lifecycle hooks', () => {
const { api } = register()
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
})
test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
// Declaring a schema without a purge is refused: a module that can create
// tables and cannot drop them leaves an operator with orphaned data.
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
// serves, so an entry in the module root would publish the whole module.
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
})
test('the manifest declares no extension slot it does not fill', () => {
const { api } = register()
// §11.3 of the plan reads `extensions` as "declared, and held against reality
// by the loader". Only the first half is true: the loader checks that a named
// slot EXISTS (`registries.hasSlot`) and never checks that the module went on
// to fill it — `checkDeclared` covers `mounts` alone. So a declaration with
// nothing behind it loads cleanly and means nothing, which is exactly why this
// module does not write one until it has an extension to register.
//
// The other half of that correction: `admin.users.detail` is the ONLY server
// slot core declares. `site.footer.status` is a CLIENT slot and is registered
// from the chunk — naming it here would fail the load with
// `unknown extension slot "site.footer.status"`.
const declared = manifest.extensions || []
const filled = api.record.extensions.map((e) => e.slot)
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
})
test('the Team provider is registered, whole, with the page core links to (phase 9)', () => {
const { api } = register()
const provider = api.record.teamProvider
// The three required methods, the optional fourth (D48's roster audience),
// and the fifth member, which is DATA: core substitutes `{externalId}` and
// nothing else, so the page cannot be nested under its server (D56).
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders', 'projectRoster']) {
assert.strictEqual(typeof provider[name], 'function', `${name} must be a function`)
}
assert.strictEqual(provider.pageUrlTemplate, '/rust/clans/{externalId}')
})
test('nothing is registered that has nothing behind it yet', () => {
const { api } = register()
// The phase-1 statement, written down so that removing it is deliberate. A
// declared trigger nothing emits and a declared slot nothing fills are both
// surfaces an operator can configure and then wait on — worse than an absent
// one, because the absence is visible. Each of these arrives with the phase
// that has something real to put in it, and this assertion is what that phase
// deletes. Phase 9 deleted the Team provider's line.
assert.strictEqual(api.record.triggers, null)
assert.strictEqual(api.record.audiences, null)
assert.strictEqual(api.record.engagementSeeds, null)
assert.strictEqual(api.record.streams, null)
assert.strictEqual(api.record.eventBudgets, null)
assert.strictEqual(api.record.eventOptionSources, null)
assert.strictEqual(api.record.eventLeases, null)
assert.strictEqual(api.record.eventActions, null)
})
test('the modules protocol version agrees with the manifest it ships beside', () => {
const sidecar = require('../sidecarClient')
// The wire version is declared in three repos — here, `PROTOCOL_VERSION` in
// the sidecar, and `overlay.toml` in the plugin overlay — and nothing in one
// repo can check the other two. What CAN be checked is that this repo says one
// thing: the number the client sends is the number an operator sees on a
// freshly created server row, so a bump that edits one and not the other
// configures every new server against a version the client does not speak.
assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number')
assert.ok(sidecar.PROTOCOL_VERSION >= 1)
})
test('an identity capability is declared, and it is the module id (phase 5, D16)', () => {
// Core flattens every started module's capabilities into ONE list, so a client
// asking "is this module installed" needs a string only this module can
// declare. `servers` is not that string — it names a surface, and another
// module could name it too — which is the whole reason this one exists beside
// the five surface words.
//
// It is asserted against `manifest.id` rather than against the literal "rust"
// so that the two cannot drift: the day the id changes, the capability a
// client gates a whole navigation group on has to change with it.
assert.ok(
manifest.capabilities.includes(manifest.id),
`module.json must declare "${manifest.id}" as a capability — it is the only string a client can` +
' use to tell this module apart from any other, and the Android app gates its Rust rows on it',
)
})

View File

@@ -0,0 +1,134 @@
// ── The field an error has to be in ───────────────────────────────────────
//
// **The walk found this, and no test could have.** Core's request primitive is
// the only thing that reads a module's failures:
//
// const message = (data && data.message) || res.statusText || 'Request failed'
//
// So a body shaped `{ error: '…' }` is not rendered as a worse message — it is
// not rendered at all. The player sees `Service Unavailable`, which is what the
// link page showed for every one of the four sentences this phase exists to
// write, until a browser said so.
//
// This module answered `{ error }` from its first phase and got away with it,
// because until now every failure 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 four are not interchangeable.
//
// The rule is core's `Error` schema (`{ message }`), which every one of this
// module's `#swagger.responses` already pointed at. So this suite is the schema
// those annotations claim, asserted against what the handlers actually send.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore() {
require('../core')._reset()
require('../core').init(fakeCtx())
}
/** A response double that records the status and the body. */
function fakeRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code
return res
},
json(body) {
res.body = body
return res
},
}
return res
}
/** Every outcome `redeem` can answer, and the status each has to become. */
const OUTCOMES = [
[{ ok: false, reason: 'taken', username: 'someone-else' }, 409, /already linked to someone-else/],
[{ ok: false, reason: 'unsure' }, 503, /still good/],
[{ ok: false, reason: 'offline' }, 503, /unreachable/],
[{ ok: false, reason: 'no-servers' }, 503, /No Rust servers/],
[{ ok: false, reason: 'rejected' }, 400, /unknown or has expired/],
]
test('every refusal reaches the player as a sentence, in the field core reads', async () => {
for (const [outcome, status, matches] of OUTCOMES) {
withCore()
const links = require('../model/links/links.model')
const controller = require('../router/player/rust.controller')
links.redeem = async () => outcome
const res = fakeRes()
await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res)
assert.equal(res.statusCode, status, `${outcome.reason} must be ${status}`)
assert.equal(typeof res.body.message, 'string', `${outcome.reason} sent no \`message\``)
assert.match(res.body.message, matches)
// The half that is easy to leave behind while fixing this: a body carrying
// BOTH fields reads correctly in a browser and keeps the wrong shape alive
// for the next route that copies it.
assert.equal(res.body.error, undefined, `${outcome.reason} still carries an \`error\` field`)
}
})
test('the five outcomes are five different statuses-and-sentences, not one', async () => {
const seen = new Set()
for (const [outcome] of OUTCOMES) {
withCore()
const links = require('../model/links/links.model')
const controller = require('../router/player/rust.controller')
links.redeem = async () => outcome
const res = fakeRes()
await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res)
seen.add(res.body.message)
}
// "That code is wrong" and "we could not reach the server that has it" send a
// player to do different things, and one of the two is a dead end when it is
// wrong — they run /link again on the server that is down and get the same
// answer for as long as it stays down.
assert.equal(seen.size, OUTCOMES.length, 'two outcomes tell the player the same thing')
})
test('no handler in this module answers in a field core cannot read', async () => {
// The other controllers, the same way — driven rather than grepped, because the
// shape that matters is what a handler SENDS. Each is given a model that throws,
// which is every controller's own 500 path and the one branch they all have.
withCore()
const cases = [
['public', '../router/public/rust.controller', 'listServers', { params: {}, query: {} }],
['player', '../router/player/rust.controller', 'listServers', { params: {}, query: {}, user: { id: 4 } }],
['player', '../router/player/rust.controller', 'listLinks', { params: {}, user: { id: 4 } }],
['admin', '../router/admin/rust.controller', 'listServers', { params: {}, query: {} }],
['slot', '../router/admin/usersRust.controller', 'listLinks', { params: { id: '4' } }],
]
for (const [tier, modulePath, handler, req] of cases) {
withCore()
// Core's `query` is the fake's spy; make it throw so every handler takes its
// failure branch.
require('../core')._reset()
require('../core').init(fakeCtx({
db: { query: () => Promise.reject(new Error('the database is not there')), pool: {} },
}))
const controller = require(modulePath)
const res = fakeRes()
await controller[handler](req, res)
assert.equal(res.statusCode, 500, `${tier}.${handler} did not fail`)
assert.equal(typeof res.body.message, 'string', `${tier}.${handler} sent no \`message\``)
assert.equal(res.body.error, undefined, `${tier}.${handler} answers in \`error\``)
}
})

Some files were not shown because too many files have changed in this diff Show More