Compare commits
4 Commits
2f24236993
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe8864939 | |||
| e35880e713 | |||
| 54b4059091 | |||
| dfdb0a3f63 |
122
android/PLAN.md
122
android/PLAN.md
@@ -1307,6 +1307,128 @@ push, and Play (M6–M8) follow the designed app.
|
||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
||||
participation history self-scoped, proved by two accounts rather than asserted.
|
||||
|
||||
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
|
||||
module** reached its first public pages in `module-rust` phase 4
|
||||
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
|
||||
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
|
||||
route here existed and answered before a line of Kotlin was written.
|
||||
|
||||
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
|
||||
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
|
||||
what the app does about it.
|
||||
|
||||
**No backend work beyond one word.** The five public routes were live. The one change is
|
||||
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
|
||||
|
||||
#### Why this is not the shard screens with a different name
|
||||
|
||||
The two games have genuinely different shapes, and collapsing them would have cost the app the
|
||||
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
|
||||
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
|
||||
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
|
||||
which case both trees exist at once and neither row appears on a site without its module.
|
||||
|
||||
| Screen | Route | Reads |
|
||||
| --- | --- | --- |
|
||||
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
|
||||
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
|
||||
|
||||
#### Four decisions, taken by the org lead on 2026-09-16
|
||||
|
||||
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
|
||||
string, `shard`, because that is the only question a capability can answer: *is the module
|
||||
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
|
||||
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
|
||||
single list, so gating on `servers` would let another module declaring that word silently reveal
|
||||
these screens on a site that does not run Rust. Gating on the module **id** was considered and
|
||||
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
|
||||
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
|
||||
same thing would quietly end that separation. So the module declares its own name as a sixth
|
||||
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
|
||||
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
|
||||
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
|
||||
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
|
||||
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
|
||||
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
|
||||
was closed in engagement Phase 8.
|
||||
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
|
||||
|
||||
#### A refresh is not a load, and the app had only ever done loads
|
||||
|
||||
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
|
||||
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
|
||||
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
|
||||
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
|
||||
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
|
||||
|
||||
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
|
||||
failure with rows on screen keeps them and reports the failure beside them; a failure with
|
||||
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
|
||||
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
|
||||
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
|
||||
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
|
||||
|
||||
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
|
||||
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
|
||||
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
|
||||
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
|
||||
|
||||
#### The drawer badge is D15 translated, not D15 copied
|
||||
|
||||
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
|
||||
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
|
||||
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
|
||||
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
|
||||
reader says "42 players online" rather than "42". The count rides there, and keeps the website
|
||||
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
|
||||
read keeps the last number, and it never polls. It is asked for only where the module is
|
||||
installed, so a UO site makes no request at all.
|
||||
|
||||
#### Verified
|
||||
|
||||
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
|
||||
that has never reported.
|
||||
|
||||
**Both halves of the phase criterion, directly.** With its server unreachable and reading
|
||||
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
|
||||
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
|
||||
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
|
||||
|
||||
Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was
|
||||
stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line);
|
||||
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
|
||||
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
|
||||
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
|
||||
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
|
||||
|
||||
#### The walk found three defects, and 644 green tests found none of them
|
||||
|
||||
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
|
||||
alone, so it was read at connect and never again — which is not what *live* means on a row
|
||||
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge.
|
||||
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
|
||||
no padding of its own; each caller pads its own content, and these four did not. On a phone the
|
||||
first glyph of each line read as clipped.
|
||||
- **A name touched its own kill count.** Five numeric columns beside an equal-weight name column
|
||||
left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes,
|
||||
and the **active sort is marked on the header** rather than by tinting a column of numbers — the
|
||||
header is the control, and tinting the values says *these are special* instead of *this is what
|
||||
the table is ordered by*.
|
||||
|
||||
#### The rig note worth keeping
|
||||
|
||||
The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only**
|
||||
— not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||
`adb reverse tcp:<port> tcp:<port>` and the loopback address; typed as `10.0.2.2` every request
|
||||
fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the
|
||||
connect screen reports — correctly, and indistinguishably from a core that is not running.
|
||||
|
||||
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
|
||||
surface. Server configuration, the sidecar token and the connection test are admin
|
||||
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
||||
and C (phases 8 and 11), and the map is leg D.
|
||||
|
||||
### Deferred (not a milestone)
|
||||
|
||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||
|
||||
@@ -982,8 +982,8 @@ Each phase ends with its findings written down, as every workstream here does.
|
||||
| 2 | **Packaging and release.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
||||
| 3 | **The read path, on both frameworks.** ✅ **Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
|
||||
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
|
||||
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
||||
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
||||
| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged |
|
||||
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
||||
| 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
||||
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
|
||||
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
||||
@@ -2583,6 +2583,306 @@ file and nothing else on the site changes.
|
||||
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
|
||||
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
|
||||
|
||||
## 18. Phase 5 as built — Android leg A, 2026-09-17
|
||||
|
||||
The first leg of R10, and the first time this module's surface has had a second client. The app's
|
||||
own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what
|
||||
the phase decided and what the walk found.
|
||||
|
||||
**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it
|
||||
has never seen, and a UO site unchanged.* Both halves were shown on one device against two running
|
||||
cores.
|
||||
|
||||
### 18.0 Four org-lead decisions
|
||||
|
||||
- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every
|
||||
one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens
|
||||
every started module's capabilities into one list, so a client gating a whole navigation group on
|
||||
`servers` would have those screens revealed by any future module that declared the same generic
|
||||
word. `module-uo` has exactly one string for this job — `shard` — and this module had none.
|
||||
|
||||
**Gating on the module `id` was considered and rejected.** It is the strongest fact available and
|
||||
it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory
|
||||
core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a
|
||||
capability. Letting a client gate on `id` makes the two the same value in practice, and the day one
|
||||
builds `/<id>/servers` from it the separation that lets this module move its own pages is gone.
|
||||
|
||||
So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js`
|
||||
asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id
|
||||
changes, the string a client gates on has to change with it. Module-Rust#5.
|
||||
|
||||
- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate,
|
||||
translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at
|
||||
all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED`
|
||||
was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader
|
||||
who is not reading.
|
||||
|
||||
- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases
|
||||
cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust,
|
||||
Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on
|
||||
`[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is
|
||||
what makes the cutover the release.
|
||||
|
||||
- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is
|
||||
small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an
|
||||
added link to `/rust`, hands off to a browser rather than opening the native screen.
|
||||
|
||||
### 18.1 D15 has no analogue on a phone, so it was translated
|
||||
|
||||
The footer slot works on the web because every public page renders the same footer (§17.4). The app
|
||||
has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a
|
||||
precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot,
|
||||
with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*.
|
||||
|
||||
The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an
|
||||
empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a
|
||||
failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked
|
||||
for only where the module is installed, so a UO site makes no request at all.
|
||||
|
||||
**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one
|
||||
is how many people are on — a server count changes when an operator edits configuration, which is not
|
||||
news, and is on the page the row opens anyway.
|
||||
|
||||
### 18.2 What the app had to grow: a refresh that is not a load
|
||||
|
||||
The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace.
|
||||
That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one
|
||||
tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the
|
||||
killfeed, render a spinner in its place and re-fill it, three times a minute, for ever.
|
||||
|
||||
`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when
|
||||
it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point:
|
||||
|
||||
| Result | What the reader sees |
|
||||
| --- | --- |
|
||||
| It answered | New rows. Nothing else. |
|
||||
| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. |
|
||||
| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. |
|
||||
|
||||
Only the **visible** live panel is polled. The website can afford to mount the one tab it is
|
||||
rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking
|
||||
at. The leaderboard and the wipe list are never polled at all.
|
||||
|
||||
**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because
|
||||
what is on screen is an answer to something the reader has stopped asking — leaving it up would show
|
||||
last wipe's killfeed under this wipe's heading.
|
||||
|
||||
### 18.3 What the walk proved, and how
|
||||
|
||||
The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server
|
||||
(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported.
|
||||
A second core on `:3100` serves `module-uo` and no Rust.
|
||||
|
||||
- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its
|
||||
map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence
|
||||
board and its wipe history. Nothing on the screen is a live call to a game host.
|
||||
- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas
|
||||
/ Leaderboards / Market and **no Rust row**.
|
||||
- **`refreshInto` against a genuinely dead backend.** The core was stopped with the list on screen. A
|
||||
poll tick later the rows were unchanged, under one line reading *"Could not refresh just now. This
|
||||
is the last thing the site heard."* — no spinner, no error page, nothing blanked.
|
||||
- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears
|
||||
only in the current wipe — **drops out** of the August board rather than reading zero.
|
||||
- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC
|
||||
kill with the prefab read as words; a suicide; an environment death (the fall that must not read as
|
||||
a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect
|
||||
with reason and session length; and *while sleeping*.
|
||||
- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each
|
||||
carrying its date — the §17.2 defect, not re-introduced in Kotlin.
|
||||
- **The presence panel saying which it is.** The offline server's board rendered under *"The last
|
||||
board this server sent. It is offline, so this is who was on then — not who is on now."*
|
||||
- **The badge**, showing a live count on the drawer row.
|
||||
|
||||
### 18.4 The walk found three defects, and a green suite found none of them
|
||||
|
||||
1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone,
|
||||
so it was read when the app connected and never again — which is not what *live* means on a row
|
||||
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge and for
|
||||
the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible
|
||||
only by backgrounding the app and returning to it.*
|
||||
2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and
|
||||
nothing more — it carries no padding, and each caller pads its own content. Four new call sites did
|
||||
not, and on a phone the first glyph of each line read as clipped.
|
||||
3. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left
|
||||
*Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes — and the
|
||||
**active sort moved to the header**, because the header is the control: tinting a column of numbers
|
||||
says *these are special* where tinting the header says *this is what the table is ordered by*.
|
||||
|
||||
### 18.5 The rig note worth keeping
|
||||
|
||||
The app's debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost`
|
||||
only** — not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||
`adb reverse tcp:<port> tcp:<port>` and the loopback address. Typed as `10.0.2.2`, every request fails
|
||||
with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, and the connect
|
||||
screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not
|
||||
running.
|
||||
|
||||
Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with
|
||||
*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD
|
||||
was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because
|
||||
an `install -r` over an earlier install keeps the stored base URL.
|
||||
|
||||
### 18.6 What is not proven here
|
||||
|
||||
- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase
|
||||
gave the surface a second client rather than re-reading the first, and the pages have still not been
|
||||
looked at in a narrow browser.
|
||||
- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown
|
||||
by seeding a player count on the unreachable server and reading the badge before the 30-second
|
||||
refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not.
|
||||
|
||||
|
||||
## 19. Phase 6 as built — identity, 2026-09-21
|
||||
|
||||
R1, and R13's first slot. A player proves a Steam account is theirs by typing `/link` in game and
|
||||
entering the code on the website; an operator sees the result inside core's own user page.
|
||||
|
||||
**Criterion met for everything the site owns, and walked in a browser** — the Steam id, the link
|
||||
date and per-server all-time totals render under core's security panel on `/admin/users/:id`, with a
|
||||
staff unlink that writes an audit row. **The in-game half is written down as a walk to run** rather
|
||||
than claimed: a code reaches a player and nobody else, so no console can read one. It is
|
||||
[`../../rust-link/PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md)'s new *identity walk*, beside the
|
||||
read-path walk phase 3 left there for the same reason.
|
||||
|
||||
### 19.0 The org-lead decisions this phase needed
|
||||
|
||||
Four were taken while it was being built (D20-D23) and three during the walk (D24-D26):
|
||||
|
||||
- **D20 — `/link` is a CHAT command, not a console one.** The Rust idiom, and what players expect
|
||||
from every Discord-linking server they have used. Both frameworks consume a `/` command rather
|
||||
than broadcasting it and `SendReply` addresses one player, so neither the request nor the code
|
||||
reaches public chat.
|
||||
- **D21 — pending codes live in plugin memory, matching `module-uo`.** Asked as "where does an
|
||||
unconfirmed code live"; answered *"match how it works for the uo module"*, and the UO bridge's
|
||||
shape was then read out of `BridgeAccountLink.cs` rather than guessed: six characters, five-minute
|
||||
TTL, thirty-second cooldown, one outstanding code per player. A plugin reload drops them, which
|
||||
matters because phase 7b's config editor will reload plugins routinely — and the cost is a player
|
||||
typing `/link` again, which is cheaper than an unconfirmed credential in a second process.
|
||||
- **D22 — the code alphabet has no O, 0, I or 1.** A player reads it off their screen and types it
|
||||
into a browser, often on a phone, sometimes reading it to somebody else.
|
||||
- **D23 — a Steam id another website account holds is REFUSED, never moved**, and the refusal names
|
||||
the holder. 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 way out is `/unlink` in
|
||||
game, which the player can reach from the machine they are sitting at.
|
||||
- **D24 — the website asks EVERY configured server, first `link.ok` wins.** A code is minted by one
|
||||
server and nothing in it says which. Asking the player to pick was rejected: a wrong pick comes
|
||||
back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous.
|
||||
- **D25 — staff can sever a link from the `admin.users.detail` panel**, with an activity row. It is
|
||||
the counterweight to D23: a player who has lost access to that Steam account in game has no other
|
||||
route back.
|
||||
- **D26 — the activity-row overflow (§19.4, defect 3) is CORE's to fix, in its own PR.** A module may
|
||||
legitimately name an action; shortening this module's names only moves the ceiling to the next one.
|
||||
|
||||
### 19.1 What the three repos gained
|
||||
|
||||
| Repo | What |
|
||||
|---|---|
|
||||
| [Rust-Plugins][rp] | `/link` and `/unlink` chat commands, the in-memory code table with its purge timer, and `link.confirm` — the first command the **website** originates. `checkPlugin.js` grew a check for chat commands, which bind by reflection with the same silent-failure mode as hooks |
|
||||
| [Rust-Link][rl] | `POST /link/confirm` — the first route on this sidecar that is not a GET. It forwards and nothing else: it does not mint codes, does not store them, and cannot tell a good one from a bad one |
|
||||
| [Module-Rust][mr] | `rust_account_links`, the fleet loop, the player page at `/player/rust`, and both halves of `admin.users.detail` |
|
||||
|
||||
**A refused code is a `200` all the way up.** `link.ok` and `link.error` are both answers; the
|
||||
sidecar keeps its own status codes for the transport (503 game down, 504 game silent), because the
|
||||
website has to tell *"that code is wrong"* from *"the game never replied"*.
|
||||
|
||||
### 19.2 `admin.users.detail` is declared in three places, and they are three different registries
|
||||
|
||||
The slot cost more wiring than its size suggests, and each of the three is held by a different thing:
|
||||
|
||||
1. **`module.json`'s `extensions` array** — validated by the loader against the **server** registry.
|
||||
Naming a client slot there fails the load outright, which phase 1 found the hard way with
|
||||
`site.footer.status`.
|
||||
2. **`api.registerExtension('admin.users.detail', router)`** in `server/index.js` — the routes,
|
||||
mounted inside core's `/api/v1/admin/users/:id` with `mergeParams`. Without that flag
|
||||
`req.params.id` is `undefined` and every statement in the panel silently scopes to nobody.
|
||||
3. **`registry.registerExtension(ID, 'admin.users.detail', Component)`** in the chunk — the panel.
|
||||
Core passes it `userId` and nothing else, so it builds its own client for the routes the server
|
||||
half registered.
|
||||
|
||||
**The gate is core's and it 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 these
|
||||
routes — which is right for a surface that can sever what phases 7 and 13 grant against.
|
||||
|
||||
### 19.3 The hole the slot found in this repo's own OpenAPI generator
|
||||
|
||||
`swaggerFragment.js` ran `register()` against a recording api and walked `record.routes` — the three
|
||||
tiers. A slot router is not registered under a tier, so **the two routes under `/admin/users/:id`
|
||||
were generated by nothing**: a fragment that was internally consistent, passed every check in the
|
||||
repo, and described two routes fewer than the module serves.
|
||||
|
||||
A slot's mount is **core's**, so it cannot be derived from anything here — it is a fourth constant
|
||||
beside `TIER_BASE`, and like `TIER_BASE` it is held to account by a real core in the frozen-manifest
|
||||
job. That check was verified to catch exactly this, by deleting the two paths from the fragment and
|
||||
watching it fail.
|
||||
|
||||
`test/frozenManifest.test.js` grew the other half. Its *mounts agree* case was written in phase 1
|
||||
with this phase named in a comment — *"when the slot arrives this test must grow the exception
|
||||
deliberately, rather than a route outside every declared mount arriving unnoticed"* — and it failed
|
||||
on the first run after the slot was filled. It now also fails when a **declared slot contributes no
|
||||
route**, because core never checks that a declared slot was filled.
|
||||
|
||||
### 19.4 Three defects the browser walk found, and 122 green tests did not
|
||||
|
||||
1. **Every refusal sentence was invisible.** Core's request primitive is the only thing that reads a
|
||||
module's failures, and it reads one field:
|
||||
|
||||
```js
|
||||
const message = (data && data.message) || res.statusText || 'Request failed'
|
||||
```
|
||||
|
||||
This module has answered `{ error: … }` since phase 1 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.** The link page showed
|
||||
*Service Unavailable* for all four of the refusals this phase exists to write. All 23 error bodies
|
||||
in the module now answer in `message`, and `test/errorShape.test.js` drives each outcome rather
|
||||
than grepping for the field.
|
||||
|
||||
It is also a correction to phases 1-4, which shipped the wrong shape while referencing core's
|
||||
`Error` schema — `{ message }` — in their own `#swagger.responses` annotations.
|
||||
|
||||
2. **The player saw a stale name.** `/player/rust` showed `Wanderer-old` — the name recorded at link
|
||||
time — while the admin panel showed `Wanderer`, the name the game last saw. Only the admin read
|
||||
joined `rust_players`. The same person, labelled two ways on one site, because a Rust name changes
|
||||
on a whim.
|
||||
|
||||
3. **Core's activity row collides with a long action name.** `Dashboard.jsx` renders the action in a
|
||||
`width: 110`, `flex: 'none'` span with no overflow handling, so `rust.account.unlink.staff`
|
||||
overlaps the detail text beside it. D26 sends it to core as its own change.
|
||||
|
||||
### 19.5 What the walk proved, and how
|
||||
|
||||
Against a core at the pinned ref with the module installed, two configured servers (one sidecar up
|
||||
with no game behind it, one address with nothing listening) and three logins:
|
||||
|
||||
- **The criterion**: `/admin/users/2` rendered the Steam id, *linked last month on rust-oxide*, *last
|
||||
played 12 hours ago*, and per-server all-time totals — 59 kills across two wipes on one server, 3
|
||||
on another — under core's own security panel.
|
||||
- **D25**: Unlink removed the row, the panel then rendered *nothing at all* (most users have no Rust
|
||||
account, and a "no linked accounts" notice on every user page is noise), and
|
||||
`rust.account.unlink.staff` landed in the activity log naming the operator.
|
||||
- **The player page**: the link row with its own Unlink, the empty state, and the three-step
|
||||
instruction that is the only place on the site a player learns the code comes from the game.
|
||||
- **A refusal that is a sentence**: with both sidecars unreachable, *"The game servers are unreachable
|
||||
right now — try again in a minute."* — which is what defect 1 above was hiding.
|
||||
- **Ownership**: a second player deleting the first player's link gets the same `404` as one that does
|
||||
not exist, so a signed-in stranger cannot discover linked Steam ids by deleting them one at a time.
|
||||
A player reaching the admin slot route gets `403` from core's own gate.
|
||||
- **R1's rate limit, live**: ten attempts pass, the eleventh answers `429` with *"Too many link
|
||||
attempts."* The limiter is per-IP, like core's own login limiter — which means two players behind
|
||||
one address share the allowance, and that is core's policy rather than a choice made here.
|
||||
|
||||
### 19.6 What is not proven here
|
||||
|
||||
- **The code from the game.** The rig booted with the phase-6 plugin loaded and announcing protocol 3,
|
||||
and could not reach the sidecar on the development machine: no inbound firewall rule for TCP 7800
|
||||
on this Windows host, which is not a change to make from a session. The plugin half's own checks
|
||||
are green and its shape is the UO bridge's, proven; what is untested is the whole path with a
|
||||
person in it. **D27 (org lead): it goes in the manual walk document**, as its own *identity walk*
|
||||
beside phase 3's player walk.
|
||||
- **`unsure` against a real refusal.** Proving it needs one server that genuinely refuses a code —
|
||||
which needs a plugin connected — alongside one that is down. The branch is unit-tested and its
|
||||
sentence was read in a browser; the live combination is step 6 of the identity walk.
|
||||
|
||||
---
|
||||
|
||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||
|
||||
@@ -220,6 +220,21 @@ how long the sidecar keeps raw events. The permanent record — per-wipe totals
|
||||
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
||||
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
||||
|
||||
**From protocol 3 your players can link their Steam account.** In game they type `/link` and the
|
||||
server answers them privately with a six-character code; on the website they type that code in
|
||||
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
|
||||
website owns the record, and `/unlink` in game asks it to let go.
|
||||
|
||||
Two things an operator should know about it:
|
||||
|
||||
- **The code is never in a frame.** It reaches the player and nobody else, which is what makes typing
|
||||
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
|
||||
`account.link.requested`, a staff-visible note that somebody asked.
|
||||
- **A Steam account can belong to one website account at a time, across your whole fleet.** A code
|
||||
from any of your servers links for all of them. If somebody links the wrong account the site
|
||||
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
||||
the admin panel.
|
||||
|
||||
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
|
||||
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
|
||||
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||
|
||||
@@ -98,3 +98,32 @@ Not "frames arrived". Three things, and the third is the one worth slowing down
|
||||
|
||||
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
||||
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
||||
|
||||
---
|
||||
|
||||
## The identity walk (protocol 3, phase 6)
|
||||
|
||||
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
|
||||
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
|
||||
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
|
||||
three steps below.
|
||||
|
||||
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
|
||||
link the same Steam account to.
|
||||
|
||||
| # | Do this | You should see |
|
||||
|---|---|---|
|
||||
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
|
||||
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
|
||||
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
|
||||
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
|
||||
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
|
||||
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
|
||||
|
||||
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
|
||||
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
|
||||
|
||||
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
|
||||
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
||||
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
||||
account at a time, and every refusal is a sentence that tells the player what to do next.
|
||||
|
||||
@@ -50,8 +50,8 @@ it is listening without one.
|
||||
|
||||
## 2. Versioning
|
||||
|
||||
The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places
|
||||
that must agree:
|
||||
The wire version is a single integer — **3** as of identity (§9) — declared in **four** places that
|
||||
must agree:
|
||||
|
||||
| Where | Repo |
|
||||
|---|---|
|
||||
@@ -333,7 +333,7 @@ writing the file and generating the token if they are missing — and prints it
|
||||
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
|
||||
it, and each is a version bump:
|
||||
|
||||
- identity and the in-game link code (phase 6)
|
||||
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
||||
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
|
||||
- clans, for core's Team provider (phase 9)
|
||||
- leases, budgets and the event actions (phases 12-13)
|
||||
@@ -458,6 +458,8 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
|
||||
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
||||
| `server.initialized` | `OnServerInitialized` | public | — |
|
||||
| `server.shutdown` | `OnServerShutdown` | public | — |
|
||||
| `account.link.requested` | `/link` chat command *(protocol 3)* | **staff** | steamId, name, ttlSec — **never the code** |
|
||||
| `account.unlinked` | `/unlink` chat command *(protocol 3)* | **staff** | steamId, name, origin |
|
||||
|
||||
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
|
||||
and every community site shows it; a **structure's** grid is where somebody lives, which is why
|
||||
@@ -585,3 +587,118 @@ a game host is a wipe-day outage waiting for a busy month.
|
||||
|
||||
---
|
||||
|
||||
## 9. Protocol 3 — identity
|
||||
|
||||
R1's identity link, and the first message in this bridge that the **website** originates. Everything
|
||||
in protocol 2 was the game talking, or the sidecar asking the game to repeat something it already
|
||||
knew.
|
||||
|
||||
The shape is the one the UO bridge proved: the player asks in game, the plugin mints a one-time code
|
||||
and hands it to them privately, and the website redeems it through the sidecar.
|
||||
|
||||
```
|
||||
player plugin sidecar website
|
||||
│ /link │ │ │
|
||||
├────────────────────►│ mint code, hold it │ │
|
||||
│◄────── code ────────┤ in memory, 5 min │ │
|
||||
│ ├─ account.link.requested ►│ ───── feed ───────►│
|
||||
│ │
|
||||
│ ………… the player types the code into the website ……………………………►│
|
||||
│ │ │◄ POST /link/confirm ┤
|
||||
│ │◄──── link.confirm ───────┤ │
|
||||
│ ├───── link.ok ───────────►│ ── steamId, name ──►│
|
||||
│ │ (code spent) │ │
|
||||
```
|
||||
|
||||
**Nothing about the link is stored in the game.** The site is the author of record, which is not a
|
||||
preference: there is no per-account store in Rust that survives a wipe, and phase 7 makes the site
|
||||
authoritative anyway — it pushes permissions *into* the game keyed by Steam id. A copy on the game
|
||||
host would be a second thing to reconcile every wipe, answering no question better.
|
||||
|
||||
### 9.1 `/link` and `/unlink` are CHAT commands, and the reply is private
|
||||
|
||||
`[ChatCommand("link")]`. Both frameworks consume a `/` command rather than broadcasting it, and
|
||||
`SendReply` addresses one player — so neither the request nor the code reaches anybody else's chat.
|
||||
That is load-bearing rather than polish: **a code read off a stream is a code somebody else can
|
||||
spend.**
|
||||
|
||||
`/unlink` emits rather than deletes, because the plugin holds no link to delete. It exists because
|
||||
the website **refuses** to move a Steam id another account already holds (D23): without a way out, a
|
||||
player who linked the wrong account while signed in as it would need staff. The authority on that
|
||||
path is the Steam account itself — whoever is connected to the game as it is who it is.
|
||||
|
||||
### 9.2 The code is **not** on the wire
|
||||
|
||||
`account.link.requested` carries the Steam id, the name and the TTL, and **never the code**. The
|
||||
event exists so an operator can see linking being used and so the site can see a player fishing; it
|
||||
is not how the code travels. The code travels **through the player**, which is what makes typing it
|
||||
into a signed-in browser proof that they are the one who asked.
|
||||
|
||||
Both account frames are **staff** class (§8.5). Neither carries a secret, but both name a Steam id
|
||||
beside a website account's activity, and that join — *this player is that person* — is a fact about
|
||||
somebody's identity rather than about what happened on the server.
|
||||
|
||||
### 9.3 `link.confirm` — website → plugin
|
||||
|
||||
The first inbound command that is not a request to repeat something.
|
||||
|
||||
```json
|
||||
{ "cmd": "link.confirm", "reqId": "r-42", "code": "K7M2PQ" }
|
||||
```
|
||||
|
||||
Answered with `link.ok` carrying `steamId` and `name`, or `link.error` carrying a `reason` of
|
||||
`unknown`, `expired` or `malformed`. Both are replies, correlated by `reqId` like `server.status`.
|
||||
|
||||
**A code is consumed on the FIRST lookup, whether or not it turns out to be expired.** The removal
|
||||
happens before the expiry check rather than after it, so a code cannot be probed twice.
|
||||
|
||||
**`unknown` and `expired` are separate here and identical to the player.** An operator reading a log
|
||||
wants to know whether codes are being guessed or merely going stale; a stranger typing codes must not
|
||||
learn which of the two they hit, because that is the difference between "keep guessing" and "guess
|
||||
faster".
|
||||
|
||||
### 9.4 The code itself
|
||||
|
||||
Six characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — **no O, 0, I or 1**, because a player reads
|
||||
this off their screen and types it into a browser, often on a phone. A five-minute TTL, a
|
||||
thirty-second cooldown per player, **one outstanding code each** (a new `/link` drops the old one),
|
||||
and a purge timer, because an unconfirmed code is never looked up and nothing else would ever remove
|
||||
it.
|
||||
|
||||
They live in plugin memory and nowhere else. A plugin reload drops every pending code — and phase
|
||||
7b's config editor will reload plugins routinely — but the cost of that is a player typing `/link`
|
||||
again, which is cheaper than an unconfirmed credential living in a second process.
|
||||
|
||||
### 9.5 `POST /link/confirm` — the first route on this sidecar that is not a GET
|
||||
|
||||
```
|
||||
POST /link/confirm { "code": "K7M2PQ" } → 200 { "kind": "link.ok", "steamId": "765…" }
|
||||
→ 200 { "kind": "link.error", "reason": "unknown" }
|
||||
→ 503 the game is not connected
|
||||
→ 504 the game is up and did not answer
|
||||
```
|
||||
|
||||
**A refused code is a `200`.** `link.ok` and `link.error` are both answers; the sidecar reserves its
|
||||
own status codes for the transport, because the website has to tell *"that code is wrong"* from
|
||||
*"the game never replied"* to say the right thing to a player (§4.3).
|
||||
|
||||
The sidecar validates nothing but the shape — it trims the code, bounds its length, and forwards it.
|
||||
Only the game holds the pending codes, and putting the table here instead would give the sidecar a
|
||||
credential and an opinion, which D2 and the bridge principles say it has neither of.
|
||||
|
||||
### 9.6 The website asks EVERY server (D24)
|
||||
|
||||
A code is minted by one server, and the player types six characters into a browser. Nothing in the
|
||||
code says which server it came from, so the module asks each configured server 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 holds it.
|
||||
|
||||
Asking the player to pick was rejected: a wrong pick comes back indistinguishable from a wrong code.
|
||||
|
||||
The consequence for this protocol is worth stating, because it is the shape of every later
|
||||
fleet-wide command: **"every reachable server refused" is not the same answer as "a server could not
|
||||
be reached"**, and a module that collapses them tells the player whose server is down that their code
|
||||
is wrong — so they fetch another code from the same server and hear it again.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user