docs(teams): phase 4 — the forum's access model, switches and image policy
Records what building phase 4 settled, and what it disproved. The structural correction first: TEAMS.md 3.1 gave the forum a CORE page and phase 3 deleted every core Team page. The ROUTES were unaffected — they are all /player and /admin — but the participant surface had no home, and 5.4's route table did not notice. Settled the way phase 3 settled the activity feed: module-uo declares a second place on its guild page and core fills it, so the phase spans two repos rather than the one the plan named. Two slots rather than one, because a slot holds one component and the first fill wins; the panel navigates by search param because a thread must be linkable and core cannot mount a route on a page it does not own. Two findings from the sanitiser worth not re-deriving: `rel` has to be on the allowlist for the transform that WRITES it to survive, or every forum link ships without noopener; and the bare-URL linkifier runs after sanitising, over escaped text only, which is the property that makes it safe rather than an injection point. Also recorded: the upload sweep runs regardless of the current image mode, which is the mechanism behind the dialog's promise that disabling uploads does not delete what is already there; the two routes the table lacked; and the org lead's decision that all three proposed acknowledgement additions ship. BACKEND_DESIGN gains the four forum tables and the reasoning a reader of the schema alone would miss — why the guard is at the route and never at the data, why no stored body ever contains an <img>, what `uploads` mode hardens, and what the acknowledgement actually records. MODULE_API's inverted-slot section gains the rule a module needs: one slot per PLACE, not one per page. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -525,7 +525,7 @@ have been found earlier, because until then no caller had ever passed a non-null
|
||||
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
|
||||
[`MODULE_API.md`](MODULE_API.md) Part 4.
|
||||
|
||||
### The seven Team tables — core's, populated by a module (Teams phases 2–3)
|
||||
### The eleven Team tables — core's, populated by a module (Teams phases 2–4)
|
||||
|
||||
A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is
|
||||
in them, through the team provider; core stores that answer, gates it and displays it. Every table
|
||||
@@ -539,10 +539,47 @@ core's.
|
||||
| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive |
|
||||
| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine |
|
||||
| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection |
|
||||
| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in this phase so the access resolver is written once; the grant flow lands with the forums |
|
||||
| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in phase 2 so the access resolver is written once; the grant flow is phase 4's |
|
||||
| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings |
|
||||
| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core |
|
||||
|
||||
| `team_forum_threads` | forum threads (phase 4). The FULL schema lands with announcements, including the `type`, `pinned` and `locked` columns only discussion uses — phase 5 opens paths rather than migrating data |
|
||||
| `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `<img>` |
|
||||
| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped |
|
||||
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
|
||||
|
||||
**The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off
|
||||
means every forum route answers **404** — not 403, which would advertise a feature the operator
|
||||
deliberately turned off — while threads, posts, grants and notification preferences are all untouched.
|
||||
Re-enabling restores the forum exactly as it was. That is the same principle as the module disabled
|
||||
guard ([`MODULE_API.md`](MODULE_API.md) §4.5).
|
||||
|
||||
**The author never writes an `<img>` tag, and that is what makes the image policy enforceable.** The
|
||||
shared sanitiser (`utils/sanitizeHtml.js`) allows `<img>` from any host — it is tuned for the admin
|
||||
editor, where the author is trusted — so the forum derives its own profile in which `img` is never
|
||||
allowed in any mode. An author writes a URL; core's renderer decides at READ time whether it becomes a
|
||||
picture, under `teams_forum_images` (`disabled` | `remote` | `uploads`). Three properties follow: the
|
||||
policy cannot be evaded, since the only code that can emit an `<img>` is core's; flipping it back to
|
||||
`disabled` un-renders every image on every existing post with **no data migration**; and there is no
|
||||
author-supplied `srcset`, `onerror` or `style` to smuggle anything through. `https:` only, on an
|
||||
extension allowlist, with `referrerpolicy="no-referrer"` and `loading="lazy"` — and **the server never
|
||||
fetches a user-supplied URL**, which would be an SSRF vector; the browser does.
|
||||
|
||||
**`uploads` mode assumes a hostile uploader**, which the admin upload path never had to. Beyond that
|
||||
path's 8 MB cap, mimetype allowlist and random filename it adds: magic-byte sniffing (a client's
|
||||
`Content-Type` is a claim, not a fact), a rolling per-account byte quota, an attribution row per file,
|
||||
and a nightly sweep that removes soft-deleted files past retention plus never-referenced orphans. The
|
||||
sweep runs regardless of the current mode — an operator who turns uploads off still has the files.
|
||||
|
||||
**Selecting `uploads` requires a recorded acknowledgement.** `PUT teams_forum_images = 'uploads'` is
|
||||
rejected **400** unless the same request carries `acknowledge: <version>`; the admin checkbox is how
|
||||
the gate is presented, never the gate. The accepted TEXT VERSION is stored in
|
||||
`teams_forum_uploads_ack`, whose `updated_by`/`updated_at` answer who and when, plus an `activity_log`
|
||||
row. If the wording is ever revised the stored version goes stale — uploads **keep working**, a
|
||||
persistent banner requires re-acknowledgement, and no other forum setting may be saved until it is
|
||||
given. `teams_forums_enabled` and `teams_forum_images` are published in `settings.getPublic()`; the
|
||||
acknowledgement is not.
|
||||
|
||||
**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth
|
||||
failure, so retention ships with the feed rather than after someone notices: a nightly worker applies
|
||||
an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap
|
||||
@@ -583,7 +620,7 @@ serving the refusal gates below: `roster_synced_at`, because `team_sync_state` h
|
||||
stamp that Team's page would report the module's last success as its own; and `members_empty_since`,
|
||||
the per-Team twin of `pending_empty_since`.
|
||||
|
||||
Design of record: [`TEAMS.md`](TEAMS.md) Part 2. The contract surface a module sees is
|
||||
Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a module sees is
|
||||
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user