docs(teams): phase 6 as built — four deviations and a ninth contract member

Part 6 gains an as-built header rather than a rewrite, so the reasoning that
produced the original design stays legible beside what the build learned.

Four deviations. There was no web notification settings screen to add the Team
list to — `/auth/me/notifications/*` was built for the app in M7 and had zero web
consumers, which is survivable for push and not for a sink whose whole argument is
the web-only user. Email defaults to `off` rather than `digest`, on the org lead's
call: digest-by-default would start mailing every member of every Team the moment
an operator connects Gmail. Roster events tickle but do not email. And a ninth
member joined MODULE_API 1.6.0.

`pageUrlTemplate` is the member, and it exists because phase 3 left core with no
Team page and therefore no way to link to one. It joins 1.6.0 in place under the
rule set 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`.

Two further build decisions are recorded where they belong: the digest computes at
send time and keeps no queue (§6.4), and one-click unsubscribe is a stateless HMAC
whose whole capability is muting one (user, Team) pair (§6.4).

BACKEND_DESIGN gains the table, the two `/auth/me` routes and the unsubscribe
endpoint — the only write in the public tier and the only route with no `siteMode`,
because the mail went out before the site went into maintenance.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:35:49 -05:00
parent 04eb859a48
commit 5e284d5468
3 changed files with 132 additions and 16 deletions

View File

@@ -552,6 +552,7 @@ 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_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_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`,
@@ -770,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/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/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. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
@@ -919,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/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 |
| 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. |
Public content GETs pass through the **siteMode** gate (§5).