docs(teams): the Teams bet, as built (Teams cutover 6/6) #169
@@ -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
|
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
|
||||||
[`MODULE_API.md`](MODULE_API.md) Part 4.
|
[`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
|
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
|
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_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_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_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_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_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
|
**`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
|
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
|
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`,
|
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`.
|
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.
|
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1202,11 +1202,19 @@ forced it is worth stating because it will recur:
|
|||||||
```js
|
```js
|
||||||
// In the module's entry chunk, at registration time:
|
// In the module's entry chunk, at registration time:
|
||||||
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
||||||
|
registry.declareModuleSlot(ID, 'uo.guild.forum')
|
||||||
|
|
||||||
// In the module's page, from the UI kit:
|
// In the module's page, from the UI kit:
|
||||||
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
|
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
|
||||||
|
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**A module declares one slot per PLACE, not one per page.** `module-uo` declares two on the same guild
|
||||||
|
page — core fills the first with the Team activity feed and the second with the Team forum — because a
|
||||||
|
slot holds one component and the first fill wins. Collapsing them into one would hand core the
|
||||||
|
decision about where each of its contributions sits, on a page the module owns. Two also keeps them
|
||||||
|
independent: a deployment with the forum switched off renders the feed unchanged.
|
||||||
|
|
||||||
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
|
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
|
||||||
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner
|
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner
|
||||||
readable at the fill site.
|
readable at the fill site.
|
||||||
|
|||||||
@@ -833,6 +833,7 @@ named for a *place* and never for a meaning):
|
|||||||
> | Slot | Declared by | Rendered in | Filled by core with | Props |
|
> | Slot | Declared by | Rendered in | Filled by core with | Props |
|
||||||
> | --- | --- | --- | --- | --- |
|
> | --- | --- | --- | --- | --- |
|
||||||
> | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` |
|
> | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` |
|
||||||
|
> | `uo.guild.forum` | `module-uo` | the same page, below the feed | the Team forum (Part 5) — added in phase 4 | `{ externalId, moduleId }` |
|
||||||
>
|
>
|
||||||
> Core's fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk,
|
> Core's fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk,
|
||||||
> so when core registers a fill the slot does not exist yet. A fill for a slot no installed module
|
> so when core registers a fill the slot does not exist yet. A fill for a slot no installed module
|
||||||
@@ -1057,6 +1058,19 @@ Every forum route above answers **404** while `teams_forums_enabled` is off, and
|
|||||||
answer 404 in any image mode but `uploads` — the same guard, applied at two levels, for the same
|
answer 404 in any image mode but `uploads` — the same guard, applied at two levels, for the same
|
||||||
reason (§5.5.1).
|
reason (§5.5.1).
|
||||||
|
|
||||||
|
> **Amended 2026-08-18 (phase 4).** §3.1's `/teams/:slug/forum/*` core page is gone with the rest of
|
||||||
|
> them. The routes below are unchanged — every one is `/player` or `/admin` — but the participant
|
||||||
|
> surface is core's fill of the module-declared `uo.guild.forum` slot (§3.4), so a reader is on
|
||||||
|
> `module-uo`'s guild page throughout. Two routes were added that this table did not have:
|
||||||
|
> `GET /api/v1/player/teams/:slug/grants` (a leader has to SEE the guests before managing them) and
|
||||||
|
> `GET /api/v1/admin/teams/forum/settings`, which serves the one piece of forum state that is not a
|
||||||
|
> public settings key — whether the uploads acknowledgement has been given, by whom, and whether the
|
||||||
|
> notice has been reworded since (§5.5.6 keeps that key unpublished).
|
||||||
|
>
|
||||||
|
> The grant routes deliberately answer while the forum is switched OFF, which no line below says: a
|
||||||
|
> toggle-off revokes no grant and the rows stay authoritative (§5.5.1), so the access list has to stay
|
||||||
|
> manageable during one. What the switch guards is the forum's CONTENT.
|
||||||
|
|
||||||
Under `/player` for the same reason as §2.11: a forum participant may be a plain player, and the tier
|
Under `/player` for the same reason as §2.11: a forum participant may be a plain player, and the tier
|
||||||
gate is `requireAuth`. Every route resolves access through the §2.5 resolver — never by checking
|
gate is `requireAuth`. Every route resolves access through the §2.5 resolver — never by checking
|
||||||
membership directly, which is how paths 1 and 3 would drift back together.
|
membership directly, which is how paths 1 and 3 would drift back together.
|
||||||
@@ -1248,6 +1262,9 @@ text **version**. Recording two booleans would add nothing — there is no reach
|
|||||||
operator consented to one clause and not the other and proceeded anyway — while the version is what
|
operator consented to one clause and not the other and proceeded anyway — while the version is what
|
||||||
actually answers the question that matters later: *which text did they agree to?*
|
actually answers the question that matters later: *which text did they agree to?*
|
||||||
|
|
||||||
|
> **Settled 2026-08-18 (org lead): all three additions below are IN**, and the build ships them —
|
||||||
|
> 1 and 3 in the help text, 2 in the dialog.
|
||||||
|
|
||||||
**Three additions proposed on top, marked so they can be dropped.** Each closes a gap the text above
|
**Three additions proposed on top, marked so they can be dropped.** Each closes a gap the text above
|
||||||
does not currently cover; none is liability language, so none changes what is being agreed to:
|
does not currently cover; none is liability language, so none changes what is being agreed to:
|
||||||
|
|
||||||
@@ -2150,7 +2167,43 @@ guild called "Admin" cannot put an official-looking page on the site.
|
|||||||
|
|
||||||
**Ships:** the whole public Team experience. Independently valuable with no forum and no Discord.
|
**Ships:** the whole public Team experience. Independently valuable with no forum and no Discord.
|
||||||
|
|
||||||
### Phase 4 — Forum 5a: access model + announcements + admin controls (`website`)
|
### Phase 4 — Forum 5a: access model + announcements + admin controls (`website` + `module-uo`)
|
||||||
|
|
||||||
|
> **Amended 2026-08-18, while building this.** Six corrections. The first is structural and follows
|
||||||
|
> from phase 3; the rest were found by building the thing described below.
|
||||||
|
>
|
||||||
|
> **The forum had nowhere to live, and §5.4's route table did not notice.** §3.1 gave it
|
||||||
|
> `/teams/:slug/forum/*` — a CORE page — and phase 3 deleted every core Team page. The routes are
|
||||||
|
> unaffected (they are all `/player` and `/admin`), but the participant SURFACE had no home. Settled
|
||||||
|
> by the org lead the same way phase 3 settled the activity feed: **`module-uo` declares a second
|
||||||
|
> place on its guild page, `uo.guild.forum`, and core fills it.** So this phase spans two repos, not
|
||||||
|
> the one named above.
|
||||||
|
>
|
||||||
|
> **Two slots rather than one**, because a slot holds one component and the first fill wins. Stacking
|
||||||
|
> the feed and the forum into a single fill would take from the module the ability to place core's
|
||||||
|
> two contributions separately on its own page, which is the whole point of the module owning it.
|
||||||
|
>
|
||||||
|
> **The forum panel navigates by SEARCH PARAM (`?thread=12`), not by route.** A thread has to be
|
||||||
|
> linkable and core cannot mount a route for one — the route belongs to the module's page. A search
|
||||||
|
> param gives a shareable URL under whatever path the module chose, with no core route anywhere in
|
||||||
|
> it. It is why the fill is one component holding both a list view and a detail view.
|
||||||
|
>
|
||||||
|
> **`rel` had to be added to the forum sanitiser's allowed attributes to make links SAFER, not
|
||||||
|
> laxer.** The profile writes `rel="noopener noreferrer nofollow"` through a transform, and
|
||||||
|
> sanitize-html strips any attribute not in the allowlist — including one its own transform just
|
||||||
|
> added. Without the entry every forum link shipped without `noopener`, silently.
|
||||||
|
>
|
||||||
|
> **The bare-URL linkifier is a second pass, and its ordering is the security property.** §5.5.3 says
|
||||||
|
> an author writes a URL and core renders the picture, which requires the URL to have become an
|
||||||
|
> anchor on the way in. Linkifying runs AFTER sanitising, over the sanitiser's own output and only on
|
||||||
|
> text outside tags: every text node is HTML-escaped by then, so the matched URL is safe in both the
|
||||||
|
> href and the link text. Running it first would be an injection point.
|
||||||
|
>
|
||||||
|
> **The upload sweep runs whether or not `uploads` is the current mode**, which is not obvious and is
|
||||||
|
> the point. An operator who turns uploads off after a problem still has the files; a sweep that
|
||||||
|
> switched itself off with the setting would strand exactly the bytes they were trying to be rid of —
|
||||||
|
> and it is the mechanism behind the dialog's promise that disabling does not delete.
|
||||||
|
|
||||||
|
|
||||||
`team_forum_grants`, the grant/revoke flow with audit into `activity_log`, leader vs staff authority,
|
`team_forum_grants`, the grant/revoke flow with audit into `activity_log`, leader vs staff authority,
|
||||||
the full forum schema, announcement threads, and the leader/staff grant UI.
|
the full forum schema, announcement threads, and the leader/staff grant UI.
|
||||||
|
|||||||
Reference in New Issue
Block a user