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).

View File

@@ -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
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
`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.
> **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
> 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.
@@ -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.
**`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 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]
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
// => Promise<{ ok, members }> // members = [memberKey]
pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method
// e.g. '/uo/guilds/{externalId}'
// authoritative
{ 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`,
`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
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
@@ -1201,19 +1232,23 @@ forced it is worth stating because it will recur:
```js
// 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.forum')
// 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.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.
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
guild page — core fills them with the Team notification control, the activity feed and the Team forum
— 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, and the module does
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
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner

View File

@@ -1487,6 +1487,35 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads (
## 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
Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the
@@ -1556,9 +1585,23 @@ CREATE TABLE IF NOT EXISTS team_notification_prefs (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
Applied as a subtraction from the computed recipient set. Surfaced as a mute toggle on the Team page
and as a list under the existing notification settings screen (`GET|PUT
/auth/me/notifications/teams`), which the Android app can adopt without a new screen concept.
Applied as a subtraction from the computed recipient set**in SQL, not in the caller**: there is no
function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient list, because one
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
@@ -1574,13 +1617,43 @@ same event**, not a fourth pipeline.
- **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
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
notification feature gets marked as spam. Default to a daily digest per Team with an immediate
option, stored in `team_notification_prefs` as a `email_mode ENUM('off','digest','immediate')`
column.
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
`team_notification_prefs`.
> **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.
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
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
is already being built there.
@@ -2390,12 +2463,17 @@ window later.
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`)
### Phase 6 — Team notifications (`website` + `module-uo`) — **DONE 2026-08-18**
Four core streams, `publishToUsers` + `endpointsForUsersStream`, the recipient computation,
`team_notification_prefs`, its settings screen, and **email as the third sink** (§6.4) with digest
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/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