From b962355810ac5a9d9fa6e7c243174b1dab59430c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 05:41:14 -0500 Subject: [PATCH] docs(safety): record S11-S14 and the user-safety core, MODULE_API 1.21.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 2 +- website/BACKEND_DESIGN.md | 59 ++++++++++++++++-- website/MODULE_API.md | 91 +++++++++++++++++++++++++++- website/TEAMS.md | 18 ++++++ website/USER_SAFETY.md | 124 +++++++++++++++++++++++++++----------- 5 files changed, 252 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 74a70d1..c5af866 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ sidecar as a service, and hands you the values the website needs. | [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes | | [STEAM_SIGNIN.md](website/STEAM_SIGNIN.md) | **Plan, built 2026-10-19** — Sign in with Steam as a built-in provider beside Google and Discord (OpenID 2.0), and a Steam id for modules | | [ACCOUNT_DELETION.md](website/ACCOUNT_DELETION.md) | **Plan, 2026-10-29**: a member deletes their own account on the website and in the app, as Google Play requires. One deletion service for staff and self, an `onUserDeleting` module hook that unlinks game accounts through each module's own unlink (`MODULE_API` 1.20.0), and runicgateway.com's `/delete-account/` page as the address Play is given | -| [USER_SAFETY.md](website/USER_SAFETY.md) | **Plan, 2026-10-29** — report, block and hide on the website and in the app: an account-wide block, a member's own hide and staff's hide for everyone, with module-owned report targets (`MODULE_API` 1.21.0, after account deletion) | +| [USER_SAFETY.md](website/USER_SAFETY.md) | **Plan, 2026-10-29; core built 2026-10-30** — report, block and hide on the website and in the app: an account-wide block, a member's own hide and staff's hide for everyone, with module-owned report targets (`MODULE_API` 1.21.0, after account deletion) | | [DEMO_MODE.md](website/DEMO_MODE.md) | **Design, fully decided 2026-10-23** — the public `demo.runicgateway.com`: one flag that locks destructive actions, static logins, and a wipe every couple of hours (website#222) | | [MODERATION_APPEALS.md](website/MODERATION_APPEALS.md) | Moderation actions, content reports and the appeals flow | | [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from the shard's own ServUO files — served over the bridge since Protocol 8, so no shared filesystem | diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 3482fec..396e354 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -91,8 +91,9 @@ server/ mounted at the group root, LAST: the /me sub-routers below also match the bare /me and supply its noindex header - me.routes.js (24) /auth/me/account*, sessions, trusted - devices — router-level requireAuth. + me.routes.js (31) /auth/me/account*, sessions, trusted + devices, blocks, hidden items — + router-level requireAuth. The ONLY self-service account surface (see below); account.controller.js sits beside it and is reached from @@ -101,6 +102,8 @@ server/ password, TOTP, identities, device sessions, trusted devices, recovery codes + safety.controller.js /auth/me/blocks + /auth/me/hidden + (USER_SAFETY.md) notifications.routes.js (3) /auth/me/devices*, notifications/* mobile.routes.js + /auth/mobile/* — native bearer login mobileSso.routes.js (5) @@ -136,6 +139,8 @@ server/ of players) and the mount table appeals.router.js (4) /player/appeals appeals.controller.js + reports.router.js (1) /player/reports — file a report on + any target type (USER_SAFETY.md) (/player/shard is module-uo's) settings/ index.js owns the shared `noindex, requireAuth` gate (authenticated, ANY role) and the mount table. @@ -161,7 +166,7 @@ server/ admin.users.detail extension slot invites.router.js (3) /admin/invites — adminOnly authProviders.router.js (4) /admin/auth — adminOnly - moderation.router.js (15) /admin/moderation — modAccess + moderation.router.js (16) /admin/moderation — modAccess (admin+moderator) at router level botActivity.router.js (2) /admin/bot-activity — adminOnly activity.router.js (1) /admin/activity — staff-wide @@ -225,6 +230,9 @@ server/ wiki/ wiki.model.js + wiki.db.js settings/ settings.model.js + settings.db.js activity/ activity.model.js + activity.db.js (+) admin activity log + safety/ userSafety.model.js + userSafety.db.js, targets.js — blocks, + hides, and the one "what is this item, for this reader" + resolver reports and blocks share (USER_SAFETY.md) middleware/ (+) siteMode.js LIVE/MAINTENANCE gate for public content noindex.js X-Robots-Tag: noindex,nofollow on admin @@ -952,6 +960,33 @@ analogue to a password — and there is no hash-lookup constraint (verification unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use marker. Cleared wholesale on TOTP disable / password change / password reset. +### user_blocks / user_hidden_items — a member's blocks and "Hide for me" *(MODULE_API 1.21.0)* + +Design of record: [`USER_SAFETY.md`](USER_SAFETY.md). Both are saved on the account, so they apply on +the website and in the app, on every device (S2), and both cascade when the account is deleted. + +`user_blocks` rows are one of two kinds. An **account block** names `blocked_user_id`; a **game +block** names (`module_id`, `identity_kind`, `external_id`) — a Steam id no account has linked. Each +kind has its own unique key (`uq_ub_account`, `uq_ub_game`), and MySQL's NULL-distinct rule keeps +them apart. `display_name` is a snapshot of the name the member saw. The rules live in +`model/safety/userSafety.model.js`: + +- **The account first** (S12). A block names an item, not a person, and lands on the item author's + account whenever there is one: a forum author always, a chat speaker when the module's + `ownersOf` links their identity. +- **A game block becomes an account block** (S14) the next time the member's blocks are read, once + its identity is linked. When the member had also blocked that account directly, the rewrite would + collide with `uq_ub_account`, and the game row is deleted instead. +- **Staff cannot be blocked** (S8): refused with `403 staff` when made. A block whose target later + became staff is kept, stops applying, and is listed with `reason: 'staff'`. + +`user_hidden_items (user_id, target_type, target_id)` is one item gone from one member's view. No +existence check: a "no such item" answer would tell a member whether a thread id exists in a forum +they cannot see. + +Filtering is on the server for every reader-specific read: the forum's thread list and thread read, +and a module's own per-viewer feeds through `ctx.safety.forViewer` (S11). + ### The 27 shard tables — module-owned (module system) `shard_*` and `uo_link_config` are **not core's**. They are created and dropped by `module-uo`'s own @@ -1037,7 +1072,7 @@ core's. | `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer | | `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it | | `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error | -| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer | +| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content was the first consumer; a game module's item (`rust.chat`, MODULE_API 1.21.0) is the second. `snapshot JSON` keeps what the item said when it was reported, and `actioned_with` names what the queue did (`hide`) — both [`USER_SAFETY.md`](USER_SAFETY.md) §4.1 | **Core had no user-facing report flow of any kind before `content_reports`.** `moderation`, `mod_notes` and `appeals` are all either staff-initiated or Discord-sanction-shaped; nothing anywhere @@ -1060,6 +1095,13 @@ properties are worth carrying: again if the behaviour recurs. - **Every transition writes `activity_log`, `dismissed` included.** A queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace. +- **Since 1.21.0, one exception to "a report is not a moderation action" lives in the queue, and it is + staff's, not the reporter's**: *Hide for everyone* (`POST /admin/moderation/reports/:id/hide`) hides + the item through its own moderation — the forum's hide as a `staff` row in `team_forum_moderation`, + or the module's `hide` — and actions the report with `actioned_with = 'hide'`. Filing still changes + nothing. Every report also stores a `snapshot` of the item, read through one resolver + (`model/safety/targets.js`) that answers 404 for an item the reporter cannot see, so + `POST /player/reports` takes any target type without a Team ([`USER_SAFETY.md`](USER_SAFETY.md)). **`teams_forum_edit_window_minutes`** (0–1440, default 15) bounds how long an author may edit their own post; staff are not bound by it. It is resolved on the server **twice** — the read path stamps each @@ -1449,6 +1491,12 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout | GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) | | POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap | | DELETE | `/me/trusted-devices` · `…/:id` | cookie / bearer | — | untrust all / one (ownership-scoped) | +| GET | `/me/blocks` | cookie / bearer | — | own blocks (`account` or `game`), newest first; a game block whose identity is now linked is answered — and stored — as an account block (S14); a block on someone now staff has `applies: false, reason: 'staff'` (S8). [`USER_SAFETY.md`](USER_SAFETY.md) | +| POST | `/me/blocks` | cookie / bearer (rate-limited) | `{targetType, targetId}` | block the **author of an item**, never a named person: lands on the author's account when there is one (S12), a game identity otherwise. 403 `staff`, 404 for an item the caller cannot see, 409 `already_blocked`, 503 when the owning module does not answer | +| DELETE | `/me/blocks/:id` | cookie / bearer (rate-limited) | — | unblock (ownership-scoped; another account's id is 404) | +| GET | `/me/hidden` | cookie / bearer | — | own "Hide for me" items | +| POST | `/me/hidden` | cookie / bearer (rate-limited) | `{targetType, targetId}` | hide one item for this account; no existence check, no report | +| DELETE | `/me/hidden` · `…/:targetType/:targetId` | cookie / bearer (rate-limited) | — | show every hidden item again / one | | GET | `/me/account/recovery-codes/status` | cookie / bearer | — | remaining unused code count (never the codes) | | POST | `/me/account/recovery-codes/generate` | cookie / bearer (rate-limited, **password step-up**) | `{currentPassword?}` | regenerate the one-time recovery codes (returned once); refused when 2FA is off | | POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) | @@ -1733,7 +1781,8 @@ file a route sits in — that is the property the route manifest freezes. | POST | `/teams/:id/unhide` · `/teams/:id/display-name` | the two **gated** actions (§2.9): an admin applies at once, a **moderator** files a pending request and nothing changes publicly. The caller does not choose — the server decides from the role it re-validates on the request | | GET | `/teams/:id/grants` | the full forum-grant ledger, revoked rows included. Read-only in this phase; the grant flow lands with the forums | | POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string | -| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately | +| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists, with its stored `snapshot` (1.21.0); a module's item (`target.kind: 'module'`) is read from the snapshot. **There is no leader-facing counterpart to either route**, deliberately | +| POST | `/moderation/reports/:id/hide` | **Hide for everyone** ([`USER_SAFETY.md`](USER_SAFETY.md) §4.1, MODULE_API 1.21.0): hides the reported item through its own moderation (the forum's hide as a `staff` ledger row, or the module's `hide`) and actions the report with `actionedWith: 'hide'`; `activity_log` `moderation.report.hide`. 400 for an upload (hide the post it is in), 503 when the owning module does not answer | | GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term | | GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply | | GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` | diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 102ffb2..e6fe43d 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,30 @@ here extends the contract first, in this file, before the module is written agai Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js -const MODULE_API_VERSION = '1.20.0' +const MODULE_API_VERSION = '1.21.0' ``` The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. +**1.21.0 — report, block and hide reach a module's content** (`website/USER_SAFETY.md` §4.1, +S11-S14). Four optional additions and no removal, so minor. + +- **`api.registerReportTargets([...])`** (§2.4): the types of the module's content a member can + report, hide for themself and block the author of, and that staff can hide for everyone from the + Reports queue. `snapshot({ targetId, viewer })` answers what the item is **for that viewer**, or + `null` when it is gone or they may not see it; core answers 404 for both. +- **`api.registerGameIdentities([...])`** (§2.4): the game identity those authors are known by, and + `ownersOf(ids)`, which website account each is linked to. A block lands on the account when there + is one (S12), and a block on an unlinked identity becomes an account block once it is linked (S14). +- **`ctx.safety.forViewer(userId)`** (§2.3): one reader's blocks and hides, so the module filters its + own per-viewer feeds on the server (S11). **`ctx.safety.staffAmong(userIds)`**: which accounts are + staff, so a module can mark the lines a reader cannot block (S8, S13). +- Nothing on `window.__rg` changed: a module's client half calls core's ordinary `/player/reports`, + `/auth/me/blocks` and `/auth/me/hidden` through `api.request`. Module-uo's `^1.10.0` still + resolves and registers none. Module-Rust's `rust.chat` is the first user. + **1.20.0 — a module hears that an account is about to be deleted** (`website/ACCOUNT_DELETION.md` §4.1, AD10, AD11). One optional addition and no removal, so minor. @@ -51,7 +68,7 @@ module chunk evaluates, which is earlier than any network round trip could answe - **`started`** modules only, like the identity hooks; registered once. The payload is frozen. - Module-uo's `^1.10.0` still resolves and registers none until its own PR: with no hook, a deletion is core's cascade alone, as the admin route's delete always was. `website/USER_SAFETY.md`'s - `reportTargets` moves to 1.21.0. + report targets moved to 1.21.0. **1.19.0 — the public demo refuses a step at save** (`website/DEMO_MODE.md` §3.4, DM18). One optional member on an event action, so minor. @@ -572,6 +589,10 @@ branches lacks — so it is the only place `permits` is true between two values between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise unchanged, and so is every rule about composition narrowing rather than widening. +**1.21.0 — report, block and hide** (`website/USER_SAFETY.md` §4.1, S11-S14). +`api.registerReportTargets`, `api.registerGameIdentities` and `ctx.safety`; four optional additions, +so minor; see §1.1. Module-uo's `^1.10.0` still resolves. + **1.20.0 — account deletion** (`website/ACCOUNT_DELETION.md` §4.1, AD10, AD11). `api.onUserDeleting`, called before an account is deleted so the module unlinks its game links; it cannot veto, and a hook past 10 s is logged and skipped. One optional addition, so minor; see §1.1. Module-uo's `^1.10.0` @@ -904,6 +925,19 @@ module-uo does not need is on the list. | `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) | | `ctx.identities.list` | `(userId, provider) => Promise<[{ provider, subject, displayName, avatarUrl, linkedAt }]>`, read only | `model/userIdentities` | Steam sign-in (1.17.0) | | `ctx.demoMode` | boolean, read once when `ctx` is built | `DEMO_MODE=true` (`demo/mode.js`) | Module-Rust's console-command deny list and config-editor rules (1.18.0) | +| `ctx.safety.forViewer` | `(userId) => Promise<{ blockedUserIds, blockedIdentities: { [kind]: [id] }, hidden: { [targetType]: [targetId] } }>`, read only, owner bound | `model/safety/userSafety` | Module-Rust's events feed, filtered per reader (1.21.0) | +| `ctx.safety.staffAmong` | `(userIds) => Promise<[userId]>` | `model/safety/userSafety` | a chat line's `canBlock` (1.21.0) | + +**`ctx.safety.forViewer(userId)`** (1.21.0, `USER_SAFETY.md` S11) is what a module's per-viewer feed +leaves out for one reader. `blockedUserIds` are the accounts the reader blocks that still apply: a +game block whose identity is now linked has already become one (S14), and a block on staff is +already gone (S8). `blockedIdentities` holds only **this module's** unlinked game blocks, by kind, +and `hidden` only the reader's hides on **this module's** report targets. A signed-out reader +(`userId` null) gets empty lists. The rule for a line is: hide it when its speaker's linked account +is in `blockedUserIds`; for a speaker with no account, when their id is in `blockedIdentities`; and +when its id is in `hidden`. A route that uses it must already be read per viewer (`Cache-Control: +private`, varying on `Cookie` and `Authorization`); a shared cache in front of it would serve one +reader's filter to another. **`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with `api.registerEventTriggers` (§2.4). It is the push half of the engagement seam @@ -1035,6 +1069,8 @@ api.onIdentityLinking(async ({ userId, provider, subject }) => ({ ok: true })) api.onIdentityLinked(async ({ userId, provider, subject, displayName }) => {}) // 1.17.0 api.onDemoSeed(async (data, { accounts }) => {}) // 1.18.0 api.onUserDeleting(async ({ userId, deletedBy }) => {}) // 1.20.0 +api.registerReportTargets([{ id, label, snapshot, hide, unhide }]) // 1.21.0 +api.registerGameIdentities([{ kind, label, ownersOf }]) // 1.21.0 ``` **Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's @@ -1573,6 +1609,57 @@ api.onUserDeleting(async ({ userId }) => { the module's to record and retry: core never calls the hook twice for one deletion. - `started` modules only, in registration order; registered once; the payload is frozen. +**`registerReportTargets([...])`** (1.21.0) — what of the module's content a member can report, hide +for themself and block the author of, and that staff can hide for everyone (`USER_SAFETY.md` §4.1). + +```js +api.registerReportTargets([{ + id: 'rust.chat', // namespaced `.`, at most 32 characters + label: 'Chat line', // what the Reports queue calls it + // What the item is FOR THIS VIEWER, or null when it is gone or they may not see it. + async snapshot({ targetId, viewer }) { // viewer: { userId, role }, frozen + return { + author: { name: 'Rusty', userId: null, identity: { kind: 'steam', id: '7656…' } }, + text: 'gg ez', at: '2026-10-30T10:00:00Z', where: { server: 'main' }, + } + }, + async hide({ targetId, actor }) {}, // staff's hide for everyone; actor { id, username, role } + async unhide({ targetId, actor }) {}, +}]) +``` + +- **`snapshot` answers for the viewer.** A `null` and a missing item are one answer to the member + (404), so a report, a block or a snapshot never confirms an item they could not have seen (a line + on a server whose presence is hidden from them). `author.userId` is the linked website account if + there is one; `author.identity` names the game identity, of a kind the module registered with + `registerGameIdentities`. +- **What core keeps is clipped.** The snapshot is stored on the report, so it outlives the module + pruning its own rows. Only `author`, `title`, `text` (500 characters), `at` and up to eight flat + `where` fields are kept. +- **`hide`** is called from the Reports queue's *Hide for everyone*. The module keeps the hidden + flag, leaves the item out for everybody but staff, and shows it to staff marked so they can undo it + where it lives (S10). +- Each call has core's **5 s**; a module that does not answer is a `503` to the member, never a + silent success. `started` modules only; registered once; target ids are integers, as + `content_reports.target_id` is. + +**`registerGameIdentities([...])`** (1.21.0) — the identity a module's authors are known by, and +which website account each one is linked to (S12, S14). + +```js +api.registerGameIdentities([{ + kind: 'steam', + label: 'Steam account', + async ownersOf(externalIds) { return { '7656…': 12 } }, // ids with no account are left out +}]) +``` + +- A block from one of the module's items lands on the **account** `ownersOf` names; only an id with + no account is blocked as itself. A game block is checked again each time it is read, and once its + id is linked it is rewritten as an account block, so it then hides the account's forum posts too. +- Keyed by module and kind: each module answers only for its own links. A module that does not + answer leaves its game blocks as they are, still applying to the id. + ### 2.5 Lifecycle ``` diff --git a/website/TEAMS.md b/website/TEAMS.md index 9453624..25fe7fd 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1467,6 +1467,24 @@ as `null` and the report still lists: "somebody reported this and by the time we is a fact a moderator needs, and dropping the row would hide the pattern of a member deleting their own content the moment it is reported. +> **Amended 2026-10-30 (`USER_SAFETY.md`, MODULE_API 1.21.0).** Four changes, none to the rules above: +> +> - **A game module's content is reportable too.** The first is a Rust chat line, `rust.chat`, which +> the module registers with `api.registerReportTargets`. A second filing route, +> `POST /api/v1/player/reports { targetType, targetId, reason, detail? }`, takes any type; the forum +> route above keeps working and calls the same model. Who may see the target is asked of one resolver +> (`model/safety/targets.js`) for every type, so the new route needs no Team and still answers 404 +> for an item the reporter cannot see. +> - **`content_reports.snapshot JSON`** keeps what the item said when it was reported, so a pruned +> chat line or an edited post still shows staff what was reported. Reports filed before it have none. +> - **The queue gains *Hide for everyone*** (`POST /api/v1/admin/moderation/reports/:id/hide`). It is +> staff's, not the reporter's, so "reporting is not a moderation action" still holds: it hides the +> item through the forum's own moderation (a `staff` row in §5.3's ledger) or the module's `hide`, +> and actions the report with `actioned_with = 'hide'`. +> - **Members can hide an item for themself and block an author**, which are not reports and file +> none. Signed-in forum reads leave those out on the server, and each thread and post carries +> `canBlock`. + Mounted under the **existing** admin moderation section rather than under Teams: a staffer working a queue should have one place to work, and a report about a forum post is the same job as a report about anything else. diff --git a/website/USER_SAFETY.md b/website/USER_SAFETY.md index 7d2cd8f..f26ca55 100644 --- a/website/USER_SAFETY.md +++ b/website/USER_SAFETY.md @@ -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.