RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272):
- profiles gain the guard role, faction, relations (its own exceptions),
alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in
RunicNPC's order and words, and defaulting to "players only" (D255);
- the faction table: one site-wide table, one row per pair and both ways
(D254, D268), stored in rust_npc_factions, edited on the NPC profiles page
(PUT /admin/rust/npcs/factions), pushed to every server with its
profiles and hashed with them, and a standalone server's own pairs
adopted at its first push with the site's winning (D244, D251);
- the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally
(a clan from the new rust.options.clans source, or the team of a player)
and tether (the zone this event made), for a RunicNPC profile only
(D269, D270, D272);
- a placement may be held inside a zone (tether, D272);
- the bridge's RunicNPC API floor is 4.
Tests: 488 server, 66 client. routes.manifest.json regenerated against the
pinned core (one route added).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Admin: /admin/rust/npcs for profiles (create, change, delete, restore a
replaced one, push now) and each server's placements (list, add from a map
point, change, remove, rename, respawn). Public: the profiles a leaderboard
ranks by, one profile's ranking counted as the profile says (D247, D250), and
one player's kills by profile (D252). Player: your own kills by profile.
The Place NPCs step offers the site's profiles first, then Rust's own
(D243). rust.npc.died and rust.npc.health are triggers a phase can wait on.
A title rule can rank a profile's kills. Swagger fragment, engagement and
route manifests regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
A switch per monument label, a fleet default and a per-server override, on
Admin -> Rust visibility's live map card. Substations, caves, train tunnels,
wells and the other minor labels start hidden; every other label is drawn,
so a monument a game update adds appears. GET /map leaves hidden labels out
of the answer, so the website and the app both lose them.
No schema change: rows are map.marker.<label key> in rust_settings and
rust_map_overrides. No wire change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Two conflicts, both additive: schema.sql and purge.sql keep both phases'
tables. Swagger fragment (61 paths) and routes.manifest (67 routes)
regenerated against the pinned core; 455 server and 58 client tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Admin → Rust zone presets: named sets of ZoneManager flags and settings for
one server, several, or every server (D195), ticked in groups read from each
server's own ZoneManager list in its last hello (D211). A flag not on a
covered server is refused on save with the server named; two presets of one
name may not share a server.
rust.zone.open gains options (one line, NoBuild, radiation=10), enterMessage,
leaveMessage, delivery, dome and domeStack. The options field's dropdown is
rust.options.zone_presets, whose row VALUE is the preset's line, so picking
one copies it into the step (D210) and no published event changes when a
preset does. The line and the dome are checked against the server's hello
on save and in a dry run; bad-option and dome-unavailable are permanent.
Schema: rust_zone_presets, rust_zone_preset_servers. Swagger fragment and
routes.manifest regenerated against the pinned core f0e7d2a.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
- Schema: fourteen title columns on rust_player_wipe_stats (the two
best_* distances move by GREATEST, the rest are sums), rust_weapon_kills
(kills by weapon prefab name) and rust_title_categories (the admin's
title per category). purge.sql drops the two tables.
- Ingest: player.tally's new fields, in one statement per frame, none
for an older plugin's frame.
- Titles: 23 categories plus playtime, each naming where it is read
from (a sum, a MAX, npc_kills - animal_kills in signed arithmetic, or
a named list). The bow/melee/blade/revolver/wood/ore/plants lists are
one file, applied when a title is read. "NPC kills" ranks human NPCs
only (D209).
- A rule's text may be empty, meaning the category's title: the rule's
own, then the admin's, then the default. Typed-only-markup is still
refused. A rename forgets every server's cached answer.
- Admin API: GET /admin/rust/title-categories and
PUT /admin/rust/title-categories/:stat (empty text resets); the server
list returns them too. Swagger fragment and routes.manifest.json
regenerated (63 routes, against the pinned core).
- Screen: every category in the rule form, the category's title as a
placeholder, and a Category titles section with Save and Reset.
The weapon lists are unverified until the rig probe runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PLAN_REDESIGNS section 1.
- Every sync reads the store (perm.inventory), reconciles it against the
site's record and its ledger, and pushes. A change made in the game is
settled by the server's policy (D161): auto-adopt (default), adopt, or
revoke. The first read of a server imports everything (D198).
- Groups belong to one server unless an admin shares them (D189), in new
id-keyed tables; the old ones are copied once at boot and left unread.
Holders may be a Steam account nobody linked (D188).
- An in-game change affects that server only (D190): a grant that reaches
further gains an exception, a shared group is split.
- Never judged: a permission the server does not register right now (an
unloaded plugin is not a revocation), and a pair an event lease holds.
- A new admin API (server view, grant/revoke with everywhere-or-here,
groups by id, share/split, members, drift answers) and a screen on
PermissionsManager's flow with a state on every toggle (D162, D163, U-1).
- The announcement voice names a group by id; old name settings still read.
Walked on both rigs against the walk core: import on an existing install,
auto-adopt of a grant and a revoke, a fleet grant's exception, Kits
unloaded without loss, a shared group split, adopt and revoke policies.
Server 420/420, client 58/58, swagger, imports and route manifest current.
Refs #21
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The plugin now answers a save once the files are written, with pending and
a writeId, and reports the reload later as a config.outcome event. The save
is recorded as reloading with a settle_by of two plugin ceilings plus slack
on the database's clock; ingest settles the row by (server, writeId), only
while it is still reloading, so a replay moves nothing and a late outcome
still lands. A row past settle_by reads as lost.
GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page
polls it every two seconds, holds the Save button while it waits, and says
whether a rolled-back plugin came back on its old file. config.outcome is
a staff kind: it carries the server's log tail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The router's own isIn check answered a mode it did not know with
express-validator's "Invalid value" before titles.validateSettings could
say which words are allowed. Found on the walk; the router now checks shape
only, as every other phase-17 route does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PLAN.md §33, D134-D143. Protocol 12.
- Chat titles (D135-D137): per-server rules (stat, top N, text, colour)
that rank the current wipe, and a mode (first | all | up to N). Worked
out once in model/titles and read three ways: pushed whole to the game by
a new titleSync loop (on change, restart or wipe), and on every
leaderboard row as `titles`. Admin: PUT /servers/:id/titles.
- Group styles (D138, D139): a site group may carry all twelve BetterChat
fields (rust_perm_group_chat). They ride perm.sync with `expect` from the
pushed ledger, which gains a value column; a field changed in game is a
`chat-field` drift row with the game's value, adopted into the style or
put back. A withdrawn style is one `chat-group` retirement, never for
`default`, cleared from the ledger only once BetterChat removed it.
- The voice (D140): one fleet setting naming a styled group; news and
rust.announce chat lines carry its format and the plugin says them with
no sender. Admin: GET/PUT /voice.
- Popups (D141, D142): rust.announce gains `delivery` (still version 1,
from rust.options.delivery); each server gains news_delivery beside the
news switch; `popup-unavailable` is not retried.
- GET /servers/:id/integrations reads, live, which optional mods a server
has loaded. README lists BetterChat and PopupNotifications as optional.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Five read-only commands registered with api.registerSlashCommands:
/status, /wipe, /top, /online and /clan (D126). Every refusal is private,
and any answer narrower than public (online names, a clan roster) goes
to the caller alone (D127). No command asks a sidecar.
The next wipe (D128, D130): six nullable columns on rust_servers, a pure
nextWipe(row, now) with the zone arithmetic through Intl, computed on
every read. The public server shape gains nextWipe; the admin shape
gains the stored schedule; PUT /admin/rust/servers/:id takes the six
fields and writes them only when wipeRule is present.
server/commands joins ci/bundle.json, which checkBundle caught.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PLAN.md §30 as approved, plus D119/D120 from the build.
Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
asks map.info; a new key or hash from the free Rust+ cache (or a render
kept on disk) is fetched in slices, checked against its SHA-256 and stored
in one statement. One fetch per server, a backoff on failure, `stale`
abandons a fetch that straddles a map change. Render now (D109) is
admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
fleet default plus per-server override (D114), the players layer capped by
presence (D113), own dot and online first-party clan mates for a linked
viewer (D115, D117, D118). A layer the viewer may not see is absent from
the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
/map/live; admin /servers/:id/map/fetch and /render; the Map card on the
visibility PUT. Swagger fragment and frozen manifest regenerated.
Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
(labels only when a cell is wide enough to hold one), a legend that lists
hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.
Capability `map` declared for the Android app (phase 15).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Four event verbs and the announce leg, per PLAN.md §29:
- rust.participation.open / .collect: the plugin counts who takes part
(seconds, kills or both, in a zone this run opened or the whole server)
and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
rust_perm_run_grants (D84) unioned into the permission push, one extra
use of the kit per reward as site-held credits on perm.sync (D103),
and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
is on (D104) - a card on Admin -> Rust visibility (D106).
Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
A first-party Rust clan is a Team (R5). This module becomes the site's
Team provider and answers core from the plugin's `clans` board. Design
of record: docs/modules/rust/PLAN.md §24, D47-D58.
- The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's
identity is <serverId>:<clanId>:<createdMs> (D52), because the game
restarts clan ids whenever its clan database version changes.
- The provider (D53): getTeams is complete only when every server's
board is fresh, supported and untruncated. It is partial when some
are, and refuses when none are. Freshness is judged by the website's
clock, from when the board's `t` last advanced.
- Only a complete board may mark a clan gone. A board at the game's
100-clan ceiling (D55), or one with an unreadable row, proves nothing
about what it leaves out.
- Leadership is diffed board to board and published (D54). The five clan
events are published as team.* kinds, and written to the Team feed as
members-only lines (D49).
- Core only writes feed items for a Team it already holds. So the last 10
minutes of clan events are re-offered on each board refresh, deduped by
a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key
would be truncated into collisions.
- projectRoster and the clan page share one audience rule (D48): the
clan's linked members and staff by default, re-read from the users row.
The setting lives on Admin > Rust visibility, which also warns about
uMod Clans (D47) and the ceiling.
- Public: GET servers/:id/clans (the list is public, D58) and
GET clans/:externalId. The client adds a Clans tab and
/rust/clans/:externalId, with three module slots for core's notify,
activity and forum contributions (D56).
- Linking and unlinking an account ask core to reconcile Teams (D57).
- The clan kinds are staff-class in the public feed allowlist.
- PROTOCOL_VERSION is now 6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The 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
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
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
The two defects the phase 6 browser walk found and #6 described but did not
carry. They were written, walked and left uncommitted; `edge` still has the
shapes the walk condemned.
**Every refusal sentence was invisible.** Core's request primitive reads one
field — `(data && data.message) || res.statusText` — and this module has
answered `{ error: … }` since phase 1. It got away with it because every
failure until phase 6 landed in `ErrorState` on a page whose whole content was
missing, where a generic sentence is honest. A form is different: the sentence
IS the outcome, and the link page showed *Service Unavailable* for all four of
the refusals phase 6 exists to write. All 23 bodies now answer in `message` —
core's `Error` schema, which these routes' own `#swagger.responses` already
referenced, so the annotations stop being a claim the handlers contradict.
`test/errorShape.test.js` drives each outcome rather than grepping for the
field, and asserts the half that is easy to leave behind: a body carrying BOTH
fields renders correctly in a browser and keeps the wrong shape alive for the
next route that copies it.
**The player saw a stale name.** `/player/rust` showed the name recorded at
link time while the admin panel showed the one the game last saw — the same
person labelled two ways on one site, because a Rust name changes on a whim and
only the admin read joined `rust_players`. A LEFT JOIN, because an account can
be linked and never played on.
123 server tests, 39 client tests, `check:imports`, `check:bundle`,
`check:swagger`, `check:externals` — all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
R1's identity link, site-side, and R13's first extension slot. A player types
/link in game, the plugin hands them a six-character code privately, and they
enter it here; the site records who owns which Steam account, and an operator
sees that on core's own `/admin/users/:id` page.
**The site is the author of record and the game holds nothing.** There is no
per-account store in Rust that survives a wipe, and phase 7 needs the site
authoritative anyway — it pushes permissions INTO the game keyed by Steam id. A
copy in the game would be a second thing to reconcile every wipe, for no question
it could answer better.
## D24 — a code is minted by ONE server, so every server is asked
Nothing in six characters says where it came from. The fleet is asked in turn and
the first `link.ok` wins; the others answer `unknown` and nothing happens there,
because a code is only spent at the server that actually holds it. Asking the
player to pick was rejected: a wrong pick would come back indistinguishable from
a wrong code, and that is the one refusal which must not be ambiguous.
**"Every reachable server refused" is not the same answer as "a server was
unreachable."** Collapsing them tells a player whose server is down that their
code is wrong — so they run /link again on that same server and are told the same
thing for as long as it stays down. `unsure` is that case, and it says to try
again rather than to fetch a new code.
## D23 — a Steam id another account holds is refused, never moved
The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7
grants permissions against a link and phase 13 hangs entitlements off it, so a
silent move is an account takeover performed by typing six characters. The
refusal names the holder, because the advice is unusable without it. The INSERT
is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE
that move — and the duplicate-key error is the refusal for the race the check
above cannot close.
The way out is `/unlink` in game, which reaches the site off the ingest feed
rather than through a route (the plugin has no link to delete). D25 adds the
other way out: staff can sever a link from the admin panel, for a player who
cannot reach that Steam account in game.
## The slot, and the hole it found in this repo's own generator
`admin.users.detail` is declared in `module.json` AND registered in `index.js`
AND filled by the chunk — three places, because the server half and the client
half are different registrations that share one name.
`swaggerFragment.js` knew only about tier routers, so the two routes under
`/admin/users/:id` were generated by nothing: a fragment that was internally
consistent and described two routes fewer than the module serves. A slot's mount
is core's and cannot be derived here, so it is a fourth constant beside
`TIER_BASE` — held to account by the frozen-manifest job, which was verified to
catch exactly this by removing the two paths and watching it fail.
## Smaller things worth knowing
- **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page
re-reads after its own write; it blanks while it re-reads, which is right here
and is exactly what made it wrong for a poll.
- **Every player-portal nav row needs an `icon`** — core draws one on every row,
and the client suite says so. This module had no icons file until now, because
the public header is text buttons.
- The two new frame kinds are STAFF-only. Neither carries a code, but both name a
Steam id beside a website account's activity, and that join is not a public
fact about what happened on a server.
- The link code route carries its own rate limiter rather than core's
`accountChangeLimiter`: this is guessing somebody else's secret, not changing
your own password, and a shared counter would let one policy set the other.
Protocol 3 on all three declaration sites; 17 new tests, 136 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
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