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

@@ -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` |