docs(safety): record S11-S14 and the user-safety core, MODULE_API 1.21.0
USER_SAFETY.md: S11-S14 (asked while building the core), §3 corrected (the chat feed is already read per viewer), §4 rewritten to match what was built, §7 step 2 recorded as built and walked. MODULE_API.md: 1.21.0 (registerReportTargets, registerGameIdentities, ctx.safety). BACKEND_DESIGN.md: user_blocks / user_hidden_items, the content_reports snapshot and actioned_with, the new routes, the folder map. TEAMS.md §5.6 amended. README row updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# Report, block and hide — user safety on the website and in the app
|
||||
|
||||
**Status:** plan of record, 2026-10-29. Nothing built yet. Each phase stops for the org lead
|
||||
before the next one starts. Built after account deletion ([`ACCOUNT_DELETION.md`](ACCOUNT_DELETION.md)),
|
||||
which is why it is `MODULE_API` 1.21.0.
|
||||
**Status:** plan of record, 2026-10-29. Step 2 (core) built 2026-10-30 (§7); the rest not yet.
|
||||
Each phase stops for the org lead before the next one starts. Built after account deletion
|
||||
([`ACCOUNT_DELETION.md`](ACCOUNT_DELETION.md)), which is why it is `MODULE_API` 1.21.0. §4 was
|
||||
amended by S11-S14 (§5) when the core was built.
|
||||
|
||||
**Why now.** Google Play's content-rating questionnaire for the Android app (`android/PLAN.md` M8)
|
||||
was answered "users or user-generated content can be reported". The app shows other players' chat
|
||||
@@ -15,7 +16,7 @@ true rather than retake the questionnaire (S1).
|
||||
| Action | Who | Where | Effect |
|
||||
|---|---|---|---|
|
||||
| **Report** | any signed-in member | a chat line (web + app), a forum thread, post or upload (web; app later, §6) | a row in the existing **Reports** queue (`TEAMS.md` §5.6). Changes nothing on its own |
|
||||
| **Hide for me** | any signed-in member | the same items | that one item disappears from **their own** view, on every device. No report is filed |
|
||||
| **Hide for me** | any signed-in member | the same items, except an upload (it is shown inside a post, so hiding the post hides it) | that one item disappears from **their own** view, on every device. No report is filed |
|
||||
| **Block** | any signed-in member | the author of any of those items, unless the author is staff (S8) | everything that author writes (chat lines and forum content) disappears from the blocker's view, on the website and in the app, on every device. Undone from *Account → Blocked* |
|
||||
| **Hide for everyone** | staff (and, for their own forum, Team leaders, as today) | the Reports queue, the forum's existing moderation menu, and the item's own menu | the item disappears for everybody except staff, who see it with a *Hidden by staff* marker and can undo it |
|
||||
|
||||
@@ -46,51 +47,83 @@ staff's.
|
||||
`…/posts/:id/moderate`.
|
||||
- **Chat lines.** Each one is a `rust_events` row in Module-Rust (`kind = 'player.chat'`, with an
|
||||
`id`, `server_id`, `steam_id`, time and raw frame). The website and the app both read them from
|
||||
the public `GET /public/rust/servers/:id/events`. The app polls it signed out or in.
|
||||
the public `GET /public/rust/servers/:id/events`. The app polls it signed out or in. That route
|
||||
is already read **per viewer**: it answers `Cache-Control: private, no-store` and varies on
|
||||
`Cookie` and `Authorization`, and the app sends its bearer token on public calls. (The first
|
||||
version of this plan said the feed was shared and cached. It is not, which is S11.)
|
||||
- **No blocks or per-member hides** exist anywhere.
|
||||
|
||||
## 4. Design (§5 holds the decisions that shape it)
|
||||
|
||||
### 4.1 Core (`website`)
|
||||
|
||||
- **Module-owned report targets.** A new optional module hook, `reportTargets`, declares the types
|
||||
a module's content can be reported as (for Rust: `rust.chat`). Each type answers two questions for
|
||||
core: `snapshot(id)` (author, text, time, where it was said), and `hide(id, actor)` /
|
||||
`unhide(id, actor)`. Core files the report, stores the snapshot and runs the queue. The module owns
|
||||
the content and its hidden flag. This is a `MODULE_API` minor bump, **1.21.0**. The Integration
|
||||
Kit re-pins and gains a paragraph.
|
||||
- **Module-owned report targets** (`MODULE_API` **1.21.0**; the normative text is
|
||||
[`MODULE_API.md`](MODULE_API.md)). `api.registerReportTargets([{ id, label, snapshot, hide,
|
||||
unhide }])` declares the types a module's content can be reported as (for Rust: `rust.chat`).
|
||||
`snapshot({ targetId, viewer })` answers what the item is **for that viewer** (author, text,
|
||||
time, where it was said), or null when it is gone or the viewer may not see it. Core answers 404
|
||||
either way, so a report or a block can never confirm an item the viewer could not have seen.
|
||||
`hide`/`unhide` are staff's hide for everyone. Core files the report, stores the snapshot and
|
||||
runs the queue; the module owns the content and its hidden flag. Core's forum types are core's
|
||||
own and are not registered. The Integration Kit re-pins and gains a paragraph.
|
||||
- **Game identities** (1.21.0, S12, S14). `api.registerGameIdentities([{ kind, label, ownersOf
|
||||
}])` names the identity a module's authors are known by (Rust: `steam`). `ownersOf(ids)` answers
|
||||
which website account each is linked to, through the module's own links.
|
||||
- **One filing route for every target**: `POST /player/reports {targetType, targetId, reason,
|
||||
detail?}`. Forum types keep working through the existing forum route, which then calls the same
|
||||
model.
|
||||
detail?}`. Forum types keep working through the existing forum route, which calls the same
|
||||
model. One resolver (`model/safety/targets.js`) answers "what is this item, and may this reader
|
||||
see it" for every type, so the route needs no Team.
|
||||
- **`content_reports.snapshot JSON`**: what the item said when it was reported. Chat lines are
|
||||
pruned with the rest of `rust_events`, and a queue row must still show what was said after that.
|
||||
Forum reports fill it too, so an author editing a post after the report cannot change what staff
|
||||
read.
|
||||
- **The queue gains *Hide for everyone*** beside actioned and dismissed. It calls the forum's
|
||||
existing hide or the module's `hide`, and records the report as actioned with that action named.
|
||||
- **Blocks**: `user_blocks (blocker_user_id, kind 'user'|'game', blocked_user_id NULL, module_id
|
||||
NULL, external_id NULL, display_name snapshot, created_at)`. A game block names a game identity
|
||||
(for Rust, a Steam ID), because most people in chat have no site account. When that identity is
|
||||
linked to an account, blocking either one hides both.
|
||||
- **Per-member hides**: `user_hidden_items (user_id, target_type, target_id, created_at)`.
|
||||
- **Routes**: `GET/POST/DELETE /auth/me/blocks`, `GET/POST/DELETE /auth/me/hidden`. Their
|
||||
OpenAPI annotations and spec are regenerated as usual.
|
||||
- **Filtering.** Signed-in forum reads drop blocked authors and hidden items on the server. The
|
||||
public chat feed is shared and cached, so it cannot be filtered per reader on the server. The
|
||||
website and the app filter it with the member's lists instead, read once per session and kept
|
||||
current after each block or hide. Module client chunks reach those lists through a new client
|
||||
module-API call.
|
||||
read. A module's snapshot is clipped to known fields before it is stored.
|
||||
- **The queue gains *Hide for everyone*** (`POST /admin/moderation/reports/:id/hide`), a separate
|
||||
button beside the status buttons. It hides the item through its own moderation: the forum's hide
|
||||
(a `staff` row in the forum's ledger) or the module's `hide`. The report becomes `actioned` with
|
||||
`content_reports.actioned_with = 'hide'`, and `activity_log` records `moderation.report.hide`.
|
||||
An upload cannot be hidden from here; hiding the post it is in is the act.
|
||||
- **Blocks**: `user_blocks`. A row is either an **account block** (`blocked_user_id`) or a **game
|
||||
block** (`module_id`, `identity_kind`, `external_id`), with a `display_name` snapshot. A block
|
||||
always lands on the author's **account** when there is one (S12): a forum author, and a chat
|
||||
speaker whose identity is linked. Only an unlinked identity is blocked as itself. A game block
|
||||
whose identity is later linked is rewritten as an account block the next time it is read (S14).
|
||||
A block whose target is, or later becomes, staff stops applying and says so (S8).
|
||||
- **A block names an item, never a person**: `POST /auth/me/blocks {targetType, targetId}`. The
|
||||
server reads who wrote it through the same resolver a report uses.
|
||||
- **Per-member hides**: `user_hidden_items (user_id, target_type, target_id, created_at)`. Nothing
|
||||
checks the item exists, so a hide cannot be used to ask whether a thread id exists in a private
|
||||
forum.
|
||||
- **Routes**: `GET/POST /auth/me/blocks`, `DELETE /auth/me/blocks/:id`, `GET/POST/DELETE
|
||||
/auth/me/hidden`, `DELETE /auth/me/hidden/:targetType/:targetId`. OpenAPI regenerated.
|
||||
- **Filtering happens on the server, for every reader-specific read** (S11). Signed-in forum reads
|
||||
drop blocked authors and hidden items, and a thread the reader hid or whose author they block
|
||||
answers 404. A module filters its own per-viewer feeds with **`ctx.safety.forViewer(userId)`**:
|
||||
the reader's account blocks (S14 already applied, staff dropped), this module's own unlinked game
|
||||
blocks, and the reader's hides on this module's targets. **`ctx.safety.staffAmong(userIds)`**
|
||||
answers which accounts are staff, for a module that marks lines `canBlock` (S13). Neither the
|
||||
website nor the app holds a list of its own; each refetches after a block or a hide.
|
||||
- **Web UI.** The forum's thread and post menus gain *Hide for me* and *Block author*, beside the
|
||||
existing *Report*. A new *Account → Blocked* page lists blocks and undoes them.
|
||||
existing *Report*. *Block author* is offered only where the read says `canBlock` (never your own,
|
||||
a deleted account's or a staff member's), and asks to confirm. A new *Blocked* section on the
|
||||
Account page (both `/account` and `/admin/account`) lists blocks with *Unblock*, says when a
|
||||
block no longer applies because its target is now staff, and offers *Show them again* for
|
||||
hidden items. The Reports queue shows a module item from its snapshot.
|
||||
|
||||
### 4.2 Module-Rust
|
||||
|
||||
- `reportTargets['rust.chat']` with `snapshot` (the speaker's name and Steam ID, the line, server,
|
||||
time, and the linked account if there is one) and `hide`/`unhide`.
|
||||
- `registerReportTargets([{ id: 'rust.chat', … }])` with `snapshot` (the speaker's name and Steam
|
||||
ID, the line, server, time, and the linked account if there is one; null when the viewer may not
|
||||
see presence on that server) and `hide`/`unhide`. `registerGameIdentities([{ kind: 'steam', …
|
||||
}])` with `ownersOf` reading `rust_account_links`.
|
||||
- **Hide for everyone** sets a flag on the `rust_events` row (`hidden_at`, `hidden_by`). The events
|
||||
endpoint leaves hidden chat lines out for everybody except staff, who get them marked.
|
||||
- **The live feed's chat lines gain a menu**: *Report*, *Hide for me*, *Block player*, and for
|
||||
staff *Hide for everyone*.
|
||||
- **The events endpoint filters per reader** (S11) with `ctx.safety.forViewer`: a line is left out
|
||||
when its speaker's linked account is blocked, or, for an unlinked speaker, when their Steam ID
|
||||
is; and when the reader hid it. Signed-in readers get `canBlock` on each chat line (S13): false
|
||||
when the speaker's linked account is staff (`ctx.safety.staffAmong`) or is the reader.
|
||||
- **The live feed's chat lines gain a menu**: *Report*, *Hide for me*, *Block player* (where
|
||||
`canBlock`), and for staff *Hide for everyone*. They call core's `/player/reports`,
|
||||
`/auth/me/blocks` and `/auth/me/hidden` through the shared request primitive.
|
||||
- On the demo, all of this stays **open**: it is what a Play reviewer tests, and the wipe resets it.
|
||||
|
||||
### 4.3 Android app (chat lines only, S4)
|
||||
@@ -98,7 +131,8 @@ staff's.
|
||||
- **Long-press a chat line** for the same menu. Report opens a sheet with the six reasons and an
|
||||
optional detail. A member who is signed out gets *Sign in to report or block*.
|
||||
- **Account → Blocked** lists blocks and undoes them, mirroring the web page.
|
||||
- The feed filters with the member's lists, as in §4.1.
|
||||
- The feed is already filtered by the server (S11); the app refetches after a block or a hide and
|
||||
offers *Block player* only where the line says `canBlock`.
|
||||
|
||||
## 5. Further decisions (org lead, 2026-10-29)
|
||||
|
||||
@@ -110,6 +144,15 @@ staff's.
|
||||
| S9 | What does a block do to the blocked person? | **Nothing beyond hiding** their content from the blocker. They are not told; there are no direct messages to stop |
|
||||
| S10 | Do staff still see a chat line hidden for everyone? | **Yes, marked** *Hidden by staff*, so the hide can be checked and undone, as the forum already treats its own hidden posts |
|
||||
|
||||
Asked while building the core (org lead, 2026-10-30):
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|---|---|
|
||||
| S11 | §4.1 said the chat feed is shared and cached, so the website and the app would filter it. It is already read per viewer (§3). Filter on the server or in the clients? | **On the server.** The module filters its feed with `ctx.safety.forViewer`. No client module-API member, and the app needs no lists of its own |
|
||||
| S12 | Blocking "either one hides both" needs core to know which account a Steam ID belongs to. How? | **The RunicGateway account first** ("look up via username first … only if have to … linked steam id"). A block from a chat line lands on the speaker's linked account when there is one; only an unlinked speaker is blocked by Steam ID. A feed checks the speaker's linked account first and the Steam ID only for an unlinked speaker |
|
||||
| S13 | Offering *Block* only on non-staff lines means each line tells a signed-in member whether its speaker is linked to staff. Acceptable? | **Yes.** Signed-in readers get `canBlock` per line; signed-out readers get nothing |
|
||||
| S14 | A Steam-ID block, and the player later links that ID to an account. What happens? | **It becomes an account block** the next time it is read, so it then hides the account's forum posts too. If the account is staff it stops applying and *Blocked* says why (S8) |
|
||||
|
||||
## 6. The team forum in the app (planned, not scheduled)
|
||||
|
||||
The app has no Teams screens. `android/PLAN.md` M21 plans them: the member's Teams, a Team's page,
|
||||
@@ -122,6 +165,19 @@ Play declarations gain "user-generated content: collected". M21 says so.
|
||||
1. **This plan** (docs), with §5's answers.
|
||||
2. **Core** (`website`): §4.1, `MODULE_API` 1.21.0, tests, OpenAPI, `BACKEND_DESIGN.md`,
|
||||
`MODULE_API.md`, `TEAMS.md` §5.6 amended. One PR.
|
||||
|
||||
**Built 2026-10-30** as `website` `feature/user-safety`, with S11-S14 asked and answered on the
|
||||
way. Server and client suites green; the route, guard and engagement manifests and the OpenAPI
|
||||
spec regenerated. Walked against a throwaway database on the local MariaDB with a core on its
|
||||
own port and no modules: the schema ensured on boot; a player hid one post (gone from their view
|
||||
only); blocking a moderator's post was refused `403 staff` and the read had said `canBlock:
|
||||
false`; blocking a thread's author made an account block and took the thread off the list and
|
||||
its link; unblock brought it back, and another account's block id answered 404; a report
|
||||
through `/player/reports` reached the queue with its snapshot; a player could not read the
|
||||
queue; *Hide for everyone* actioned the report with `actionedWith: "hide"` and the post was
|
||||
gone for members and shown to staff as hidden. S14's rewrite, and its merge when the member had
|
||||
also blocked the account directly, were checked against the same database. The module half
|
||||
(`rust.chat`, `ctx.safety`) is exercised by unit tests here and walks with step 3.
|
||||
3. **Module-Rust** (`edge`): §4.2 plus the core-ref pin. One PR. **Integration-kit**: the re-pin and
|
||||
a paragraph on `reportTargets`. One PR.
|
||||
4. **Android** (`edge`): §4.3. One PR.
|
||||
|
||||
Reference in New Issue
Block a user