Compare commits
4 Commits
36dbe9c985
...
docs/teams
| Author | SHA1 | Date | |
|---|---|---|---|
| 12bd24a973 | |||
| 5e284d5468 | |||
| 04eb859a48 | |||
| aed4ec4166 |
@@ -525,7 +525,12 @@ 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 eleven Team tables — core's, populated by a module (Teams phases 2–4)
|
### The eleven Team tables — core's, populated by a module (Teams phases 2–5)
|
||||||
|
|
||||||
|
*Twelve rows in the table below: `content_reports` is listed here because Team forum content is its
|
||||||
|
first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its
|
||||||
|
`target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it
|
||||||
|
rather than a table of its own.*
|
||||||
|
|
||||||
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
|
||||||
@@ -547,6 +552,38 @@ core's.
|
|||||||
| `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_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_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 |
|
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
|
||||||
|
| `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 |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
**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
|
||||||
|
let a *member* say "this is a problem". That was survivable while every piece of content on the site
|
||||||
|
came from staff, and stops being the moment a Team forum lets players write to each other. Four
|
||||||
|
properties are worth carrying:
|
||||||
|
|
||||||
|
- **Reports reach site staff and nobody else.** A Team's leaders moderate their own forum, so a
|
||||||
|
leader-visible queue would route a complaint *about* a leader back to that leader. There is one
|
||||||
|
queue, mounted at `/admin/moderation/reports` beside appeals — a staffer working a queue should have
|
||||||
|
one place to work — and no leader-facing counterpart anywhere
|
||||||
|
([`TEAMS.md`](TEAMS.md) §5.6, org lead 2026-08-18).
|
||||||
|
- **A report is not a moderation action.** Filing one changes nothing about the content; it opens a
|
||||||
|
queue item. That keeps it clear of `team_forum_moderation`, which records things that actually
|
||||||
|
happened, and stops "report" becoming a way for any participant to hide anything.
|
||||||
|
- **One OPEN report per (target, reporter)**, enforced by a unique key over a generated `open_marker`
|
||||||
|
that is `1` while open and `NULL` once closed — the same encoding as
|
||||||
|
`team_forum_grants.active_marker`, and for the same reason: only the *live* rows may collide. A
|
||||||
|
closed report frees the slot, so a member whose first report was dismissed may raise the same target
|
||||||
|
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.
|
||||||
|
|
||||||
|
**`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
|
||||||
|
post with `canEdit`/`editableUntil` so a client knows whether to draw the control, and the write
|
||||||
|
re-derives it from `created_at` before allowing anything. The read is advice, the write is enforcement,
|
||||||
|
and the split exists because a time-bounded permission must not take its clock from the party it
|
||||||
|
bounds. It is deliberately **not** in `settings.getPublic()`: the client that needs the number is the
|
||||||
|
admin screen, and the client that needs the decision already has it per post.
|
||||||
|
|
||||||
**The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off
|
**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
|
means every forum route answers **404** — not 403, which would advertise a feature the operator
|
||||||
@@ -734,6 +771,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
|
|||||||
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
||||||
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
||||||
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
|
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
|
||||||
|
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
|
||||||
|
|
||||||
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
|
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
|
||||||
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
|
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
|
||||||
@@ -883,6 +921,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
|
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
|
||||||
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
|
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
|
||||||
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
|
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
|
||||||
|
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams |
|
||||||
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
|
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
|
||||||
|
|
||||||
Public content GETs pass through the **siteMode** gate (§5).
|
Public content GETs pass through the **siteMode** gate (§5).
|
||||||
@@ -960,6 +999,7 @@ 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 |
|
| 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 |
|
| 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 |
|
| 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 | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
|
| 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 | `/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 |
|
||||||
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
|
||||||
|
|||||||
@@ -33,14 +33,21 @@ The client half carries the same number (`client/src/modules/version.js`) and a
|
|||||||
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
|
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.
|
module chunk evaluates, which is earlier than any network round trip could answer.
|
||||||
|
|
||||||
**1.6.0 — Teams, the whole surface.** Eight additions, no removals and no changed signature, so minor;
|
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
|
||||||
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
|
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
|
||||||
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
||||||
the provider's optional `projectRoster` · `api.registerSlashCommands(...)` ·
|
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
|
||||||
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
|
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
|
||||||
|
|
||||||
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
|
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
|
||||||
>
|
>
|
||||||
|
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
|
||||||
|
> `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and
|
||||||
|
> 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not
|
||||||
|
> anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where
|
||||||
|
> a Team page actually is, so a notification email could name a Team and not link to it. See
|
||||||
|
> `registerTeamProvider` below.
|
||||||
|
>
|
||||||
> **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
|
> **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
|
||||||
> 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
|
> 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
|
||||||
> only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
|
> only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
|
||||||
@@ -395,7 +402,8 @@ Before it existed, core's post controller required `utils/newsGump` directly —
|
|||||||
naming a UO file, and the last thing binding core to the module.
|
naming a UO file, and the last thing binding core to the module.
|
||||||
|
|
||||||
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
|
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
|
||||||
module becomes the authoritative source of Teams for this deployment.
|
module becomes the authoritative source of Teams for this deployment. Two further members,
|
||||||
|
`projectRoster` and `pageUrlTemplate`, are optional and documented below.
|
||||||
|
|
||||||
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
|
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
|
||||||
one authoritative source by construction, and two modules answering "what Teams exist" would produce
|
one authoritative source by construction, and two modules answering "what Teams exist" would produce
|
||||||
@@ -410,6 +418,8 @@ getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
|
|||||||
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
|
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
|
||||||
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
|
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
|
||||||
// => Promise<{ ok, members }> // members = [memberKey]
|
// => Promise<{ ok, members }> // members = [memberKey]
|
||||||
|
pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method
|
||||||
|
// e.g. '/uo/guilds/{externalId}'
|
||||||
|
|
||||||
// authoritative
|
// authoritative
|
||||||
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
|
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
|
||||||
@@ -447,6 +457,27 @@ Core distinguishes two refusals, and a module does not have to do anything to ge
|
|||||||
serves an empty roster and says so in the response (`projected: false`,
|
serves an empty roster and says so in the response (`projected: false`,
|
||||||
`projectionUnavailable: true`).
|
`projectionUnavailable: true`).
|
||||||
|
|
||||||
|
**`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core
|
||||||
|
cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md
|
||||||
|
Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary
|
||||||
|
owns the page. That is settled and right, and it leaves core unable to write the link a notification
|
||||||
|
email needs — an email about a forum reply that cannot take you to the thread is most of the way to
|
||||||
|
useless. So the module that owns the page says where it is.
|
||||||
|
|
||||||
|
```js
|
||||||
|
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders,
|
||||||
|
pageUrlTemplate: '/uo/guilds/{externalId}' })
|
||||||
|
```
|
||||||
|
|
||||||
|
Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only** —
|
||||||
|
a template naming its own host is refused at registration, since there is no reason for a module to
|
||||||
|
redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the
|
||||||
|
member costs the deployment clickable links in Team notification email and nothing else.
|
||||||
|
|
||||||
|
**Data rather than a callback, deliberately.** A function here would put a module hook on the mail
|
||||||
|
path — one more thing that can hang or throw between a forum reply and the mail about it — to produce
|
||||||
|
a string that never varies.
|
||||||
|
|
||||||
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
|
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
|
||||||
an anonymous caller — and never the `users` row, which would make every column of that table part of
|
an anonymous caller — and never the `users` row, which would make every column of that table part of
|
||||||
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
|
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
|
||||||
@@ -1201,19 +1232,23 @@ 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.header')
|
||||||
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
||||||
registry.declareModuleSlot(ID, 'uo.guild.forum')
|
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.header" externalId={guildId} moduleId="uo" />
|
||||||
<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" />
|
<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
|
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
|
||||||
page — core fills the first with the Team activity feed and the second with the Team forum — because a
|
guild page — core fills them with the Team notification control, the activity feed and the Team forum
|
||||||
slot holds one component and the first fill wins. Collapsing them into one would hand core the
|
— because a slot holds one component and the first fill wins. Collapsing them would hand core the
|
||||||
decision about where each of its contributions sits, on a page the module owns. Two also keeps them
|
decision about where each of its contributions sits, on a page the module owns, and the module does
|
||||||
independent: a deployment with the forum switched off renders the feed unchanged.
|
use that freedom: the notification control goes **above** the roster because muting is an action *on*
|
||||||
|
the page, and the other two go below it because they are content *in* it. Separate slots also keep
|
||||||
|
them independent: a deployment with the forum switched off renders the other two 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
|
||||||
|
|||||||
307
website/TEAMS.md
307
website/TEAMS.md
@@ -1071,6 +1071,31 @@ reason (§5.5.1).
|
|||||||
> toggle-off revokes no grant and the rows stay authoritative (§5.5.1), so the access list has to stay
|
> 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.
|
> manageable during one. What the switch guards is the forum's CONTENT.
|
||||||
|
|
||||||
|
> **Amended 2026-08-18 (phase 5).** The 5b routes are as tabled, with three notes the table does not
|
||||||
|
> carry.
|
||||||
|
>
|
||||||
|
> **`POST /forum/threads` splits its authority BY TYPE rather than widening the leader gate.** An
|
||||||
|
> `announcement` stays leader-authored; a `discussion` may be opened by any forum participant —
|
||||||
|
> including a granted non-member with no game identity, which is path 3 doing its job. `type` defaults
|
||||||
|
> to `announcement`, so a phase-4 client keeps meaning what it meant; defaulting the other way would
|
||||||
|
> silently turn its announcements into discussions. The list response reports the split as **two**
|
||||||
|
> booleans, `canPost` (may open a discussion) and `canAnnounce` (leader), because a client reading one
|
||||||
|
> boolean would have to guess which right it described.
|
||||||
|
>
|
||||||
|
> **Post-level moderation is its own route**, `POST /forum/posts/:id/moderate`, rather than the thread
|
||||||
|
> route with a target kind: `pin` and `lock` describe a thread's place in a list and its openness to
|
||||||
|
> replies, neither of which a post has. The route's validator deliberately accepts **all eight**
|
||||||
|
> actions so the model can answer `pin` with *"pin applies to a thread, not to a post"* — restricting
|
||||||
|
> it to the four a post takes turns a nameable mistake into a generic validation error, which is what
|
||||||
|
> the live rig found.
|
||||||
|
>
|
||||||
|
> **Three refusal codes on a reply, chosen to be distinguishable.** 404 for a thread that is absent or
|
||||||
|
> hidden from this caller; **400** for an announcement, which takes no replies by TYPE and no retry
|
||||||
|
> fixes; **409** for a locked thread, where the request is well-formed and the resource's state is what
|
||||||
|
> refuses. Locked refuses **staff too** — they hold `unlock`, so unlock/post/relock reaches the same
|
||||||
|
> place leaving three ledger rows that say what happened, whereas a moderator's reply in a thread
|
||||||
|
> nobody else may answer is the last word by fiat.
|
||||||
|
|
||||||
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.
|
||||||
@@ -1300,6 +1325,34 @@ who accepted a liability notice is operator detail, exactly as `failure_reason`
|
|||||||
The *rendering* decision is still made server-side. The client is told the mode so it can present the
|
The *rendering* decision is still made server-side. The client is told the mode so it can present the
|
||||||
right composer; it is never the thing that decides whether an image appears.
|
right composer; it is never the thing that decides whether an image appears.
|
||||||
|
|
||||||
|
#### 5.5.7 `teams_forum_edit_window_minutes` — how long an author may edit (phase 5)
|
||||||
|
|
||||||
|
An ordinary `settings` key, `0`–`1440`, **default 15**, on the same admin screen as the other two. Set
|
||||||
|
to `0` it makes posts permanent once written, which is a legitimate operator choice rather than an
|
||||||
|
off switch — there is no state in which editing is "disabled" as opposed to "bounded at zero", and
|
||||||
|
inventing one would only give the resolver a decision to get wrong.
|
||||||
|
|
||||||
|
**Staff are not bound by it.** The window exists so a post cannot be rewritten out from under someone
|
||||||
|
quoting it, or under a moderator about to act on a report; a staffer editing another member's post is
|
||||||
|
already an intervention that writes `activity_log` (§5.3), and time-bounding it would only mean
|
||||||
|
waiting.
|
||||||
|
|
||||||
|
**It is evaluated on the server twice, on purpose.** The read path stamps every post with `canEdit`
|
||||||
|
and `editableUntil` so a client knows whether to draw the control; the write re-derives it from
|
||||||
|
`created_at` before allowing anything. Two evaluations of one rule: the read one is advice and the
|
||||||
|
write one is enforcement. A client may use `editableUntil` to WITHDRAW an offer whose deadline passed
|
||||||
|
while a page sat open, and can never create one — **a time-bounded permission must not take its clock
|
||||||
|
from the party it bounds**, which is why the window itself is not a published setting (§5.5.6) and is
|
||||||
|
served only to the admin screen that edits it.
|
||||||
|
|
||||||
|
A hidden or deleted post is editable by nobody, staff included. Restoring it is a moderation action
|
||||||
|
with a ledger row; quietly rewriting it while it is out of sight is the same act with no record.
|
||||||
|
|
||||||
|
The read fails closed to **zero**, not to the default — the opposite of what it looks like it should
|
||||||
|
do. The risk the window bounds is an author rewriting a post out from under a reader, so the safe
|
||||||
|
answer during a DB fault is "nobody may edit for the next minute". A stale uploads acknowledgement
|
||||||
|
freezes this key along with the other two: it is a forum setting.
|
||||||
|
|
||||||
### 5.6 Abuse reports — the missing half of moderation
|
### 5.6 Abuse reports — the missing half of moderation
|
||||||
|
|
||||||
**Core has no user-facing report flow of any kind today.** `moderation`, `mod_notes` and `appeals` are
|
**Core has no user-facing report flow of any kind today.** `moderation`, `mod_notes` and `appeals` are
|
||||||
@@ -1335,11 +1388,46 @@ CREATE TABLE IF NOT EXISTS content_reports (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Amended 2026-08-18 (phase 5). The table as shipped departs from the block above in four places,
|
||||||
|
> three of them corrections and one an addition.**
|
||||||
|
>
|
||||||
|
> **The unique key is on a generated `open_marker`, not on `status`, and the spelling above has a
|
||||||
|
> defect worth recording rather than quietly fixing.** With `status` in the key, CLOSED rows collide
|
||||||
|
> with each other too: a reporter reports a post, staff dismiss it, the behaviour recurs, they report
|
||||||
|
> it again — and the second dismissal is an `UPDATE` into a `(…, 'dismissed')` tuple that already
|
||||||
|
> exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The
|
||||||
|
> shipped column is `open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED`,
|
||||||
|
> the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and MySQL
|
||||||
|
> treats NULLs as distinct — so any number of closed reports coexist while at most one open one can.
|
||||||
|
> That is what the prose above actually asks for.
|
||||||
|
>
|
||||||
|
> **`handled_note VARCHAR(500)`** was added. §5.6's API takes `{ status, note? }` and the table had
|
||||||
|
> nowhere to put the note. A queue whose resolution reason lives only in an `activity_log` line is one
|
||||||
|
> where the next staffer to see a repeat report about the same content cannot find out why the last
|
||||||
|
> one was closed.
|
||||||
|
>
|
||||||
|
> **`reporter_username` and `handled_username` snapshots** were added, per §2.10: who raised a report
|
||||||
|
> and who decided it must survive the account, exactly as every other Team table already does.
|
||||||
|
>
|
||||||
|
> **`team_id` gained a real FK with `ON DELETE CASCADE`.** The block above leaves it a bare
|
||||||
|
> denormalised column; a deleted Team then leaves a queue full of reports about content that cascaded
|
||||||
|
> away with it.
|
||||||
|
>
|
||||||
|
> 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 — and
|
||||||
|
> the reports most worth auditing are exactly the ones somebody wanted gone.
|
||||||
|
|
||||||
Four rules:
|
Four rules:
|
||||||
|
|
||||||
- **Reports go to site staff, not to Team leaders.** A leader may also see and act on reports for
|
- **Reports go to site staff, and to nobody else.** *(Amended 2026-08-18, org lead, when phase 5 was
|
||||||
their own Team, but staff always receive them — the whole point is a path that routes *around* a
|
built.)* This section originally added "a leader may also see and act on reports for their own
|
||||||
Team's own leadership.
|
Team". **That half is not implemented and is not deferred — it is decided against.** The gap this
|
||||||
|
whole section exists to close is that leaders moderate their own Team's forum and a Team's leaders
|
||||||
|
are exactly the people who will not report their own Team; a leader-visible queue hands a complaint
|
||||||
|
*about* a leader straight back to them, and a read-only leader view still tells them who reported
|
||||||
|
what. There is one queue, under `/admin/moderation`, gated to admin + moderator. If a leader-facing
|
||||||
|
surface is ever wanted it is a fresh design decision, not a refactor — `content_reports.team_id`
|
||||||
|
makes it *possible*, which is not the same as intended.
|
||||||
- **Reporting is not a moderation action.** A report changes nothing about the content; it opens a
|
- **Reporting is not a moderation action.** A report changes nothing about the content; it opens a
|
||||||
queue item. This keeps it clear of §5.3's leader/staff moderation ledger, which records things that
|
queue item. This keeps it clear of §5.3's leader/staff moderation ledger, which records things that
|
||||||
actually happened.
|
actually happened.
|
||||||
@@ -1355,6 +1443,20 @@ GET /api/v1/admin/moderation/reports the queue, alongside the exis
|
|||||||
POST /api/v1/admin/moderation/reports/:id/handle { status, note? }
|
POST /api/v1/admin/moderation/reports/:id/handle { status, note? }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The player route sits behind the same `resolveForum` guard as the rest of §5.4, so a reporter is by
|
||||||
|
construction someone who can already see what they are reporting — and the model additionally checks
|
||||||
|
the target really belongs to the Team the request came through, or the queue's per-Team filter would
|
||||||
|
quietly be lying. A duplicate answers **409** rather than pretending to succeed: silently accepting is
|
||||||
|
friendlier for one tap and dishonest for the second, and a member who reports twice because nothing
|
||||||
|
seemed to happen deserves to be told the first is already in the queue.
|
||||||
|
|
||||||
|
The queue resolves every row's target in **three batched reads** keyed by target type, never one read
|
||||||
|
per row — that is rule 4 above actually paying for §5.5.4's attribution table, and the N+1 version is
|
||||||
|
how a queue becomes a thing staff avoid opening. A target that has since been hard-deleted comes back
|
||||||
|
as `null` and the report still lists: "somebody reported this and by the time we looked it was gone"
|
||||||
|
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.
|
||||||
|
|
||||||
Mounted under the **existing** admin moderation section rather than under Teams: a staffer working a
|
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
|
queue should have one place to work, and a report about a forum post is the same job as a report about
|
||||||
anything else.
|
anything else.
|
||||||
@@ -1385,6 +1487,35 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads (
|
|||||||
|
|
||||||
## Part 6 — Notifications
|
## Part 6 — Notifications
|
||||||
|
|
||||||
|
> **Built 2026-08-18 (phase 6).** As-built, and it deviates from what is written below in four
|
||||||
|
> places. Each is recorded here rather than by rewriting the section, so the reasoning that produced
|
||||||
|
> the original design stays legible next to what the build learned:
|
||||||
|
>
|
||||||
|
> 1. **There was no web notification settings screen to add the Team list to.** §6.3 says the per-Team
|
||||||
|
> mute list is surfaced "under the existing notification settings screen". No such screen existed:
|
||||||
|
> `/auth/me/notifications/*` had been built for the Android app in M7 and had **zero** web
|
||||||
|
> consumers. Tolerable while push was the only sink — push needs the app anyway. Not tolerable for
|
||||||
|
> email, whose entire argument (§6.4) is the web-only user, so the sink and the screen to configure
|
||||||
|
> it shipped together as `/account/notifications`.
|
||||||
|
> 2. **Email defaults to `off`, not to `digest`.** §6.4 specifies digest-by-default; on the org lead's
|
||||||
|
> decision it is opt-IN, because digest-by-default means every member of every Team starts
|
||||||
|
> receiving daily mail the moment an operator connects Gmail — a decision about other people's
|
||||||
|
> inboxes, made on their behalf. **Push stays opt-out.** The two sinks now default opposite ways;
|
||||||
|
> the asymmetry lives in the schema's column defaults and nowhere else.
|
||||||
|
> 3. **Roster events do not email.** All four streams exist and all four tickle. Only the two forum
|
||||||
|
> streams reach the email sink: §6.4's argument is the reply nobody hears about, and "someone
|
||||||
|
> joined the guild" arrives from a fifteen-minute sweep, is already on the activity feed, and is
|
||||||
|
> how a notification feature earns a spam complaint.
|
||||||
|
> 4. **A ninth member joined `MODULE_API_VERSION` 1.6.0** — `pageUrlTemplate` on the team provider.
|
||||||
|
> Phase 3 left core with no Team page and therefore no way to *link* to one, so an email could name
|
||||||
|
> a Team and not take you to it. The module that owns the page now says where it is. See
|
||||||
|
> [`MODULE_API.md`](MODULE_API.md) `registerTeamProvider`.
|
||||||
|
>
|
||||||
|
> Two further build decisions, neither contradicting anything above: the digest **computes at send
|
||||||
|
> time** and keeps no queue (§6.4 as-built, below), and one-click unsubscribe is a **stateless
|
||||||
|
> HMAC** rather than a token table.
|
||||||
|
|
||||||
|
|
||||||
### 6.1 What the existing pipeline gives us, and the one thing it does not
|
### 6.1 What the existing pipeline gives us, and the one thing it does not
|
||||||
|
|
||||||
Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the
|
Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the
|
||||||
@@ -1454,9 +1585,23 @@ CREATE TABLE IF NOT EXISTS team_notification_prefs (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
```
|
```
|
||||||
|
|
||||||
Applied as a subtraction from the computed recipient set. Surfaced as a mute toggle on the Team page
|
Applied as a subtraction from the computed recipient set — **in SQL, not in the caller**: there is no
|
||||||
and as a list under the existing notification settings screen (`GET|PUT
|
function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient list, because one
|
||||||
/auth/me/notifications/teams`), which the Android app can adopt without a new screen concept.
|
would be a refactor away from being used.
|
||||||
|
|
||||||
|
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
|
||||||
|
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
|
||||||
|
places:
|
||||||
|
|
||||||
|
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
|
||||||
|
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
|
||||||
|
array is required on PUT even when empty, per the Android gotcha below.
|
||||||
|
- **A mute toggle on the Team page**, filled into a THIRD module-declared slot, `uo.guild.header`.
|
||||||
|
Above the roster rather than below it, because muting is an action *on* the page and the feed and
|
||||||
|
forum are content *in* it — which is exactly the placement decision a module cannot make if core
|
||||||
|
stacks everything into one fill. It renders nothing for a viewer with no preference row available,
|
||||||
|
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
|
||||||
|
"is this person in it", and the guild page is public.
|
||||||
|
|
||||||
### 6.4 Email — the third sink, already built and unused
|
### 6.4 Email — the third sink, already built and unused
|
||||||
|
|
||||||
@@ -1472,13 +1617,43 @@ same event**, not a fourth pipeline.
|
|||||||
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
|
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
|
||||||
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
|
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
|
||||||
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
|
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
|
||||||
- **Digest, not per-event, by default.** A busy Team forum sending one email per reply is how a
|
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
|
||||||
notification feature gets marked as spam. Default to a daily digest per Team with an immediate
|
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
|
||||||
option, stored in `team_notification_prefs` as a `email_mode ENUM('off','digest','immediate')`
|
`team_notification_prefs`.
|
||||||
column.
|
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
|
||||||
|
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
|
||||||
|
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
|
||||||
|
> has.
|
||||||
|
- **The digest computes at send time and keeps no queue** (as built). The only state is
|
||||||
|
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
|
||||||
|
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
|
||||||
|
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
|
||||||
|
hid after it was written is simply not in the query; and **a user who lost forum access between the
|
||||||
|
post and the send is no longer in the recipient set**, so they are not emailed content they can no
|
||||||
|
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
|
||||||
|
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
|
||||||
|
eat a day of somebody's notifications every time the mail provider had a bad minute.
|
||||||
|
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
|
||||||
|
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
|
||||||
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
|
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
|
||||||
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
|
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
|
||||||
writes the preference the site shows.
|
writes the preference the site shows.
|
||||||
|
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
|
||||||
|
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
|
||||||
|
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
|
||||||
|
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
|
||||||
|
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
|
||||||
|
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
|
||||||
|
> once — and it exists before it is needed rather than after.
|
||||||
|
>
|
||||||
|
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
|
||||||
|
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
|
||||||
|
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
|
||||||
|
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
|
||||||
|
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
|
||||||
|
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
|
||||||
|
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
|
||||||
|
> session behind it.
|
||||||
|
|
||||||
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
|
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
|
||||||
is already being built there.
|
is already being built there.
|
||||||
@@ -1990,6 +2165,11 @@ makes §0.1's roster possible:
|
|||||||
Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard
|
Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard
|
||||||
serial dependency in the list.
|
serial dependency in the list.
|
||||||
|
|
||||||
|
**Phase 11 is the exception to "independently shippable", and it is last on purpose** (org lead,
|
||||||
|
2026-08-18). The integration kit teaches an outside audience to build against this contract; Teams
|
||||||
|
expands the contract, so the book is the last thing owed before `edge` becomes `main`. It is also the
|
||||||
|
only phase that cannot merge until the cutover exists — see its own note.
|
||||||
|
|
||||||
### Phase 0 — a one-guild roster spike (`servuo-plugins` + `link`, throwaway)
|
### Phase 0 — a one-guild roster spike (`servuo-plugins` + `link`, throwaway)
|
||||||
|
|
||||||
**Not a deliverable — insurance on the phase that gates everything else.** `servuo-plugins` has no CI
|
**Not a deliverable — insurance on the phase that gates everything else.** `servuo-plugins` has no CI
|
||||||
@@ -2231,6 +2411,34 @@ to run it, at low surface area.
|
|||||||
|
|
||||||
### Phase 5 — Forum 5b: discussion + moderation + reports (`website`)
|
### Phase 5 — Forum 5b: discussion + moderation + reports (`website`)
|
||||||
|
|
||||||
|
> **Amended 2026-08-18, while building this.** Five notes. The first is the org lead's decision; the
|
||||||
|
> rest were found by building the thing described below, or on the live rig afterwards.
|
||||||
|
>
|
||||||
|
> **Reports are site administration only.** §5.6's "a leader may also see and act on reports for their
|
||||||
|
> own Team" is decided against, not deferred — see the amendment there. It is the phase's most
|
||||||
|
> important property and it is a NEGATIVE one, so it is asserted directly in the test suite rather
|
||||||
|
> than left to be noticed: the report model's whole function surface is pinned, and `queue`/`handle`
|
||||||
|
> are checked not to mention leadership at all.
|
||||||
|
>
|
||||||
|
> **The edit window is an admin setting, not a constant** (§5.5.7), and it is evaluated on the server
|
||||||
|
> twice — once as advice on the read path, once as enforcement on the write. That is the phase's other
|
||||||
|
> structural rule: a time-bounded permission must not take its clock from the party it bounds.
|
||||||
|
>
|
||||||
|
> **§5.6's unique key does not work as written**, and the shipped table uses a generated `open_marker`
|
||||||
|
> instead. See the amendment there; it is the one place in this document where the SQL and the prose
|
||||||
|
> beside it disagreed.
|
||||||
|
>
|
||||||
|
> **This phase spans ONE repo, which is worth saying because phase 4 did not.** Phase 4 needed
|
||||||
|
> `module-uo` because the forum had no surface after phase 3 and a slot had to be declared. Phase 5
|
||||||
|
> grows the component that fills that slot, so `uo.guild.forum` is untouched and nothing in the module
|
||||||
|
> changes.
|
||||||
|
>
|
||||||
|
> **The live rig found one defect, and it was a message rather than a behaviour.** The post-moderation
|
||||||
|
> route's validator listed only the four actions a post accepts, so `pin` returned a generic
|
||||||
|
> "Validation failed" instead of the sentence written for it — leaving that branch reachable only from
|
||||||
|
> its own unit test. Walking the surface for real is what turns "documented, tested and unreachable"
|
||||||
|
> into something anyone notices.
|
||||||
|
|
||||||
Discussion threads, replies, the edit window, pin/lock/hide/delete, `team_forum_moderation`, the admin
|
Discussion threads, replies, the edit window, pin/lock/hide/delete, `team_forum_moderation`, the admin
|
||||||
ledger view, and **abuse reporting** (§5.6): `content_reports`, the report control, and the queue in
|
ledger view, and **abuse reporting** (§5.6): `content_reports`, the report control, and the queue in
|
||||||
the existing admin moderation section.
|
the existing admin moderation section.
|
||||||
@@ -2238,14 +2446,39 @@ the existing admin moderation section.
|
|||||||
Reports land here rather than in Phase 4 only because discussion is what generates them at volume — if
|
Reports land here rather than in Phase 4 only because discussion is what generates them at volume — if
|
||||||
Phase 4 ships `uploads` mode enabled anywhere before Phase 5, **pull reports forward into Phase 4**.
|
Phase 4 ships `uploads` mode enabled anywhere before Phase 5, **pull reports forward into Phase 4**.
|
||||||
An upload path with a liability acknowledgement and no way for a member to raise a problem is the one
|
An upload path with a liability acknowledgement and no way for a member to raise a problem is the one
|
||||||
combination this plan should not ship.
|
combination this plan should not ship. *(In the event, phase 4 shipped `uploads` mode with the default
|
||||||
|
off, so nothing was pulled forward.)*
|
||||||
|
|
||||||
### Phase 6 — Team notifications (`website`)
|
Also lands here, because both had existed since phase 4 with nothing rendering them: the **per-Team
|
||||||
|
forum moderation ledger** on the admin Teams screen — the `actor_role` column that keeps a leader's
|
||||||
|
housekeeping distinguishable from a staff intervention was readable only from a DB client — and
|
||||||
|
`softDeleteUploadsForPost`, which post deletion is the first caller of and which needed an inverse so
|
||||||
|
`delete` → `restore` does not return a post's words while silently losing its pictures a retention
|
||||||
|
window later.
|
||||||
|
|
||||||
|
**Acceptance, four:**
|
||||||
|
1. A member opens a discussion and a granted non-member replies to it; the same member is refused an
|
||||||
|
announcement `403` while a leader is allowed one.
|
||||||
|
2. A locked thread refuses replies at `409` from every identity **including staff**, and unlock →
|
||||||
|
reply → relock leaves three rows in the Team's ledger saying so.
|
||||||
|
3. An author edits their own post inside the window and is refused `403` outside it; staff edit the
|
||||||
|
same post at any time, and a staff edit of somebody else's post writes `activity_log` while a
|
||||||
|
member's own edit does not.
|
||||||
|
4. A member reports a post; the report reaches `/admin/moderation/reports` and answers `403` to the
|
||||||
|
Team's own leader, to the reporting member and to every other participant; handling it changes the
|
||||||
|
report's status and **nothing at all** about the content.
|
||||||
|
|
||||||
|
### Phase 6 — Team notifications (`website` + `module-uo`) — **DONE 2026-08-18**
|
||||||
|
|
||||||
Four core streams, `publishToUsers` + `endpointsForUsersStream`, the recipient computation,
|
Four core streams, `publishToUsers` + `endpointsForUsersStream`, the recipient computation,
|
||||||
`team_notification_prefs`, its settings screen, and **email as the third sink** (§6.4) with digest
|
`team_notification_prefs`, its settings screen, and **email as the third sink** (§6.4) with digest
|
||||||
mode and one-click unsubscribe.
|
mode and one-click unsubscribe.
|
||||||
|
|
||||||
|
**TWO repos, not the plan's one.** `module-uo` joined for two lines it alone can supply: a third
|
||||||
|
declared slot (`uo.guild.header`, for the mute toggle) and `pageUrlTemplate` on its team provider,
|
||||||
|
without which core cannot write a link to a Team page at all — see the four amendments at the head of
|
||||||
|
[Part 6](#part-6--notifications).
|
||||||
|
|
||||||
**Android is deliberately not in this phase** — see the deferred note in
|
**Android is deliberately not in this phase** — see the deferred note in
|
||||||
[`../android/PLAN.md`](../android/PLAN.md). The streams exist in the catalog and the app will show
|
[`../android/PLAN.md`](../android/PLAN.md). The streams exist in the catalog and the app will show
|
||||||
them as toggles automatically, but nothing here builds a Team screen or a deep-link target for the
|
them as toggles automatically, but nothing here builds a Team screen or a deep-link target for the
|
||||||
@@ -2277,6 +2510,56 @@ rendering the admin UI from the declaration rather than from a hardcoded "Discor
|
|||||||
purpose: extracting a capability surface from one working implementation is honest; designing it before
|
purpose: extracting a capability surface from one working implementation is honest; designing it before
|
||||||
one exists is speculation.
|
one exists is speculation.
|
||||||
|
|
||||||
|
### Phase 11 — the integration kit (`integration-kit`) — **the last phase before the cutover**
|
||||||
|
|
||||||
|
The kit is the instruction book for putting a *different* game on this platform, written for an
|
||||||
|
audience outside this org. Teams expands the contract that book teaches against, so the book is the
|
||||||
|
last thing this bet owes before `edge` becomes `main`.
|
||||||
|
|
||||||
|
**One sentence in it is already wrong.** `book/02-website-module.md` states, of extension slots,
|
||||||
|
"**core declares a slot; a module may only fill one**". Phase 3 inverted exactly that: with
|
||||||
|
`declareModuleSlot` a MODULE declares a place on its own page and CORE fills it, and by phase 6
|
||||||
|
`module-uo` declares three. A new game's module cannot implement Teams at all without the inverted
|
||||||
|
direction, so this is not a stale detail — it is the shape the reader needs and does not have.
|
||||||
|
|
||||||
|
**Two genuinely new shapes to teach, and only two:**
|
||||||
|
|
||||||
|
- **The inverted slot** (§3.7a) — a module declaring a place for core, why the name is namespaced under
|
||||||
|
the module's own id, and why a module wants *separate* slots rather than one (it decides where each
|
||||||
|
of core's contributions sits on a page it owns).
|
||||||
|
- **`registerTeamProvider`** — the first registration where **core calls the module and waits**. Every
|
||||||
|
other one is the module claiming a mount or core notifying it. The envelope, the 10-second budget,
|
||||||
|
and the asymmetry that matters: every call fails **stale** (core keeps what it has) except
|
||||||
|
`projectRoster`, which fails **closed**, because for a visibility question "keep what you have"
|
||||||
|
means serving the roster unprojected.
|
||||||
|
|
||||||
|
`pageUrlTemplate` is a footnote beside those — one optional string, and the reader meets it while
|
||||||
|
reading the provider.
|
||||||
|
|
||||||
|
**What this phase explicitly does NOT do: enumerate the contract.** The kit already teaches only four
|
||||||
|
members and has never mentioned `registerNotificationStreams`, `registerAnnounceLeg` or
|
||||||
|
`registerPostHook`, all of which predate Teams. That is the design, not a gap:
|
||||||
|
[`MODULE_API.md`](MODULE_API.md) is normative and the kit teaches one path end to end and links out.
|
||||||
|
The question this phase answers is "did the teaching path change", and the answer is yes in two
|
||||||
|
places and no everywhere else.
|
||||||
|
|
||||||
|
**Then the two mechanical lines:** `ci/core-ref.json`'s sha moves to the cutover commit and
|
||||||
|
`template/module.json`'s `coreApi` becomes `^1.6.0`, which puts `scripts/checkCoreApi.js` back to
|
||||||
|
green. That check is an **equality**, and its going red is the mechanism rather than a bug — a
|
||||||
|
contract bump is meant to turn that repo red until someone has re-read the chapters. Moving the pin is
|
||||||
|
that person saying they have.
|
||||||
|
|
||||||
|
> **Ordering, stated because it is genuinely awkward.** This phase is written *before* the cutover and
|
||||||
|
> can only *merge after* it. CI clones the pinned sha and checks the template against that core's
|
||||||
|
> `MODULE_API_VERSION` — and 1.6.0 does not exist on `main` until the cutover lands, so there is no sha
|
||||||
|
> to pin and no core for the template to build against until then. Write the chapters last, open the
|
||||||
|
> PR once the cutover merge exists, and put the pin move in it.
|
||||||
|
|
||||||
|
**Checks that gate it** (all dependency-free Node scripts, run from the repo root — which is also how a
|
||||||
|
reader runs them): `checkLinks`, `checkRenameSites`, `checkChapterPaths`, `checkCoreApi --core .core`,
|
||||||
|
plus the template's own `npm ci` / `check:imports` / `build` / `check:externals` / `npm test` on both
|
||||||
|
halves. Build the client **before** the client tests; two of them read the built chunk.
|
||||||
|
|
||||||
### Cross-cutting, every phase that touches the server
|
### Cross-cutting, every phase that touches the server
|
||||||
|
|
||||||
`npm run swagger` regenerated and committed · `npm run routes:manifest -- --check` zero-line diff ·
|
`npm run swagger` regenerated and committed · `npm run routes:manifest -- --check` zero-line diff ·
|
||||||
|
|||||||
Reference in New Issue
Block a user