docs(modules): the presence fix — nothing names who is online by default #262

Merged
whitlocktech merged 1 commits from docs/rust-presence-fix into main 2026-09-23 05:36:07 +00:00
3 changed files with 134 additions and 7 deletions

View File

@@ -1511,6 +1511,23 @@ push, and Play (M6M8) follow the designed app.
Phase 10 is when it will matter, because a notification about an entitlement will want
somewhere to land.
#### Amended 2026-09-22 — M14's Online tab and feed stop naming players by default
Not a milestone of its own: a correction to what M14 shipped, made on the website first
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §23). **Nothing names who is online by
default** — the module now withholds the Online list, and every feed item that says a named
player was on, from anyone below an operator-chosen audience (staff unless widened), while the
player **count** stays public.
The app's part is to never read that as *nobody is on*. `RustOnlineDto` carries `hidden`,
`count` and `audience`, and `RustEventListDto` carries `presenceHidden` and `presenceAudience`;
the repository and view model keep the whole answer rather than its rows. The Online tab says
"2 players online" and who can see the names; the feed says once, above the rows, that joins,
deaths and chat are not shown. An older module without the flags decodes as visible, as before.
Walked on an emulator: the withheld panel at the default, and the names arriving on the next poll
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
check. Branch `fix/rust-presence-visibility`.
### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is

View File

@@ -987,7 +987,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | 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). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | 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), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app |
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
@@ -3305,6 +3305,108 @@ that close it are in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
---
## 23. The presence fix — nothing names who is online by default, 2026-09-22
Not a numbered phase: a correction to what phases 4 and 5 shipped, landed **before** phase 9 on the
org lead's instruction. It came out of phase 9's roster question. Asked who may see a clan's roster,
the org lead answered with a rule wider than the phase:
> *Nothing should tell who is online by default — that should always default to the lowest blast
> area, like members and admin. We can have a default online count but not naming players.*
Measured against that rule, the public site had been breaking it since phase 4, in three places: the
server page's **Online** tab named every connected player, the public **feed** carried
`player.connected`/`player.disconnected`, and the Android app (phase 5) rendered both.
As built: [Module-Rust][mr] `fix/presence-visibility` and [Android-app][aa]
`fix/rust-presence-visibility` (both into `edge`). Core needed no change.
### 23.0 The decisions this needed
| # | Decision |
|---|---|
| **D42** | **Nothing names who is online by default.** Staff (admin or moderator) unless an operator widens it. A *count* is public at every setting — it is already on the server list and in the footer, and it names nobody. |
| **D43** | **Every kind that says a named player was on is gated**, not only connects: `player.connected`, `player.disconnected`, `player.respawned`, **`player.death`**, **`player.chat`** and `player.tally`. The org lead chose "gate all of them" over keeping the killfeed and chat public as content, so a default install's public feed carries only the server's own story (a wipe, a start, a shutdown). |
| **D44** | **Three rungs — `staff` · `signed_in` · `public`** — named to line up with phase 14's map-layer switches (public / players / admin) so that one layer can adopt them rather than sit beside them. A "linked players" rung was offered and not taken. |
| **D45** | **A fleet-wide default with an optional per-server override.** A PvE or creative server may reasonably publish a roll call a PvP server must not. A server with no override follows the fleet, so narrowing the fleet narrows every server that never chose otherwise. |
| **D46** | **The fix is its own PR, before phase 9**, not a commit inside it and not deferred to phase 14. |
### 23.1 "Who is online" was wider than the tab of that name
D43 is the org lead's; the fourth surface was found by reading the leaderboard. A leaderboard row
carries `lastSeen`, and a gather tally is flushed every minute a player is on — so a `lastSeen` of
forty seconds ago is the Online tab by another name. It is withheld below the audience too, and the
website drops the column rather than render a row of dashes that read as "never". The rows' **order**
still breaks ties on it, which says who was on more recently and never whether anybody is on now.
### 23.2 The viewer is re-read, not decoded
`ctx.auth.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, so
the module re-reads the `users` row through `ctx.users.getById` (MODULE_API 1.1.0, previously unused
here) and takes role and status from it. **Walked:** a moderator demoted in the database mid-session
lost the roll call on the same cookie, on the next request. A banned account reads as anonymous.
Any failure narrows. An unknown stored audience reads as `staff`; a viewer who cannot be resolved
reads as anonymous; a setting that cannot be read withholds rather than answering 500 or publishing.
The asymmetric pair of fallbacks is `module-uo`'s shard-visibility rule, copied rather than
rediscovered.
### 23.3 The wire shape, and why it did not change
`GET /public/rust/servers/:id/online` keeps its shape and adds three fields: below the audience it
answers `{ players: [], hidden: true, count, audience }`. A client that predates the flag renders an
empty list rather than breaking — wrong, but not a crash — and a current one says "2 players online"
and who can see the names. The feed adds `presenceHidden` and `presenceAudience`. All three routes
answer `Cache-Control: private, no-store` with `Vary: Cookie, Authorization`, because a shared cache
that stored a moderator's answer would hand the roll call to the next anonymous visitor.
Settings live in a new `rust_settings` key/value table (phase 9's roster audience is the next key)
and an optional `presence_audience` column on `rust_servers`. `GET/PUT /admin/rust/visibility` is
`requireRole('admin')` over the tier gate, validated whole before anything is written, and every save
is one activity-log row. The admin page is **Rust visibility**.
### 23.4 A defect four phases old: every empty state was a blank box
Core's `EmptyState` renders its **children** and nothing else. This module had passed it `title` and
`message` since phase 4 — the shape the Integration Kit's template teaches (`Clans.jsx`) — and React
drops an unknown prop without a word, so **every empty panel in the module rendered as an empty
box**: "Nobody is on", "No scores yet", "No servers yet", all six of them. It surfaced only because
the presence fix's own message ("2 players online") depended on one; 169 server tests and 39 client
tests could not see it.
Fixed **module-side** on the org lead's steer ("we can't change too much, we do not want to break
other integrations like the UO module"): a small `Empty` wrapper renders a heading and a sentence as
children of core's component, so nothing core or `module-uo` renders changes. A client test now
refuses a titled `EmptyState` — and a `PageHeader subtitle`, the same class of bug the kit shipped in
the Teams work — and was proven by planting an offence and watching it fail.
**Owed to the kit (phase 19):** the template's `Clans.jsx` teaches the wrong prop. Corrected in its
own Integration-kit PR alongside this one.
### 23.5 What was proven, and how
- **API, four viewers × three routes**, against a core at the pinned sha with the module installed
and a stand-in sidecar serving a board with two players and a feed with a join, a death, a tally
and a chat line: anonymous and a signed-in player got `count: 2` and no names, a feed of wipes only
and no `lastSeen`; a moderator and an admin got all of it.
- **The writes:** a moderator's `PUT` 403s; an unknown audience 400s; a request naming one unknown
server 404s and writes nothing; fleet → `signed_in` opened the names to a player and not to
anonymous; a per-server `public` override opened them to anonymous on that server alone; `null`
put it back on the fleet default.
- **The browser**, signed out: the Online tab reads *2 players online · Only this site's staff can
see who they are*; the feed says joins, deaths and chat are not shown; the leaderboard has no
"Last seen" column. The admin page was proven by API only — the walk does not type a password into
a browser — and is left for the org lead's review.
- **The emulator**, signed in as a player: the same two messages at the staff default, and the names
arriving on the next poll after the fleet was widened to `signed_in` — which also proves the app's
bearer session reaches the module's viewer check.
180 server tests, 40 client tests, 652 app tests, `check:imports`, `check:bundle`,
`check:externals`, the regenerated OpenAPI fragment and the frozen route manifest.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
---

View File

@@ -445,12 +445,12 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
| `kind` | Hook | `class` | Carries |
|---|---|---|---|
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
| `player.connected` | `OnPlayerConnected` | **presence** | steamId, name |
| `player.disconnected` | `OnPlayerDisconnected` | **presence** | steamId, name, reason, sessionSec |
| `player.respawned` | `OnPlayerRespawned` | **presence** | steamId |
| `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
| `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
@@ -486,6 +486,14 @@ So: the table in §8.4 is the specification, `module-rust` holds the allowlist,
its allowlist against this document, so adding a kind here without classifying it there fails a
build rather than shipping an IP address to a public page.
**`presence` is `public` with an audience an operator chooses** (added 2026-09-22, [`PLAN.md`](../modules/rust/PLAN.md) §23).
The six kinds marked so each say that a *named* player was on the server at a given moment, and the
org lead's rule is that nothing names who is online by default: `module-rust` serves them only to
viewers inside an operator-chosen audience — staff unless widened, fleet-wide with a per-server
override. Below it the public feed carries only what names nobody (a wipe, a start, a shutdown).
Nothing on the wire changed: the class is still the module's to enforce, which is why the rule could
be added without a protocol bump.
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
`player.reported` carries the text of one player's complaint about another. They are stored because
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they