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:
2026-10-30 05:41:14 -05:00
parent ae730b0dcb
commit b962355810
5 changed files with 252 additions and 42 deletions

View File

@@ -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 `<moduleId>.`, 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
```