From 5e284d546858e5eba641f9510682aaca7dba5677 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 14:35:49 -0500 Subject: [PATCH] =?UTF-8?q?docs(teams):=20phase=206=20as=20built=20?= =?UTF-8?q?=E2=80=94=20four=20deviations=20and=20a=20ninth=20contract=20me?= =?UTF-8?q?mber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- website/BACKEND_DESIGN.md | 3 ++ website/MODULE_API.md | 51 +++++++++++++++++---- website/TEAMS.md | 94 +++++++++++++++++++++++++++++++++++---- 3 files changed, 132 insertions(+), 16 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 33fa4c4..3dfa7c4 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 `` | | `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). diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 347caaa..a124ccc 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_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 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: + ``` -**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 diff --git a/website/TEAMS.md b/website/TEAMS.md index 58800f3..2084365 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -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