diff --git a/android/PLAN.md b/android/PLAN.md index 139de23..a0359e1 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1511,6 +1511,23 @@ push, and Play (M6–M8) 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 diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 5c92dc1..7b5fb10 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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 `/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 --- diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index 7cd93e1..01f2b8a 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -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