feat(teams): phase 6 — notifications, and the email sink the web never had #156

Merged
whitlocktech merged 6 commits from feature/teams-phase6-notifications into edge 2026-08-18 23:10:22 +00:00
Member

Teams phase 6, per docs/website/TEAMS.md Part 6. Into edge.

Companion PRs: Module-uo#13 (the third slot + pageUrlTemplate) and docs#159.

What lands

Four core streams, publishToUsers + endpointsForUsersStream, the recipient computation, team_notification_prefs, a settings screen, and email as the third sink with digest mode and one-click unsubscribe.

Team scoping lives in the recipient set, never in a stream id. The push catalog is a static registration validated at boot against a namespaced pattern, so a stream per Team is unexpressible, and stream ids live in notification_subscriptions rows that a per-Team id would leave behind every time a Team archived. One recipient query serves all four streams, because §6.2's two populations are the same set written twice — "active members with a user_id plus active grants" is "everyone with resolved forum access". Mutes are subtracted in SQL; there is no function that returns an unfiltered set.

Four deviations from the design of record

Each is in the as-built header on Part 6 rather than a rewrite, so the original reasoning stays legible.

  1. There was no web notification settings screen to add the Team list to. §6.3 says the mute list goes "under the existing notification settings screen". None existed — /auth/me/notifications/* was built for the app in M7 and had zero web consumers. Survivable while push was the only sink; not survivable for email, whose whole argument is the web-only user. So the sink and /account/notifications shipped together.
  2. Email defaults to off, not digest (org lead): digest-by-default would start mailing every member of every Team the moment an operator connects Gmail. Push stays opt-out. The two sinks default opposite ways and the asymmetry lives only in the schema.
  3. Roster events do not email. All four streams tickle; only the two forum streams reach the mail sink.
  4. A ninth member joined MODULE_API_VERSION 1.6.0pageUrlTemplate. Phase 3 left core with no Team page and so no way to link to one; an email could name a Team and not take you to it. In place rather than 1.7.0, under the phase-2 rule (a contract owes a bump only once it has landed on main).

Two build decisions worth reading

The digest computes at send time and keeps no queue. Only last_digest_at. A deployment down for two days sends one correct digest, not a replay; a post a moderator hid is 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 — that one would have been a security bug. since is clamped to seven days, and the stamp is written only on a successful send.

Unsubscribe is a stateless HMAC, not a token table. Every property that makes a reset token a row is absent. Its whole capability is setting muted for one (user, Team) pair. Two URLs come out of one token — the body's page and the List-Unsubscribe header's endpoint — because RFC 8058 one-click POSTs without rendering anything. GET on the API path redirects and does not act, or a mail-client link scanner would mute Teams nobody asked to leave. It answers 200 whatever the token was: distinguishing a forgery would be an oracle for which (user, Team) pairs exist.

Defects found while building

  • Number(null) is 0, and 0 is an integer — a null in an id list survived filter(Number.isInteger) and rode into an IN clause as user id 0. Harmless today, which is why it would never have been noticed. Fixed in all three filters, caught by a test.
  • recipientIds: db.recipientIds captured the function object at require time, so the layer below could never be substituted — the model was not the seam it claimed to be. Wrapped to resolve at call time.
  • PUT did not actually replace the set — it wrote the entries given and left the rest standing, so {"teams": []} cleared nothing, which is the one thing the mandatory-empty-array rule exists for. Found on the live rig; invisible to unit tests and to the screen, both of which send every row. Now resets omitted Teams to defaults without touching last_digest_at.

Verification

  • npm test1066 server, 248 client, both green. npm run swagger and npm run routes:manifest -- --check committed and clean.
  • Walked against the live rig (real MariaDB, real Team with 25 members, a real forum grant): schema applied at boot; the auth gate; GET/PUT prefs; an entry naming a Team the caller is not in dropped; the empty set clearing; unsubscribe POST muting, GET redirecting without acting, and a forged token indistinguishable; and the real recipient SQL returning the right sets — a mute honoured, a departed member and a revoked guest excluded, an unlinked member contributing no row, and a grant-only guest with no game account correctly included.
  • ⚠️ Not verified: the two rendered surfaces. The browser tooling failed in this environment (broken screenshots, 0×0 viewport, stale page text after navigation), so /account/notifications and the guild-page mute toggle were confirmed only by their presence in the built bundles and by their endpoints answering. The core-content-inside-a-module's-page seam is exactly what unit tests cannot see — worth a look before merge, as phase 4's panel was.

AI-assisted: written with Claude Code.

🤖 Generated with Claude Code

Teams phase 6, per [`docs/website/TEAMS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/TEAMS.md) Part 6. Into `edge`. Companion PRs: **Module-uo#13** (the third slot + `pageUrlTemplate`) and **docs#159**. ## What lands Four core streams, `publishToUsers` + `endpointsForUsersStream`, the recipient computation, `team_notification_prefs`, a settings screen, and email as the third sink with digest mode and one-click unsubscribe. **Team scoping lives in the recipient set, never in a stream id.** The push catalog is a static registration validated at boot against a namespaced pattern, so a stream per Team is unexpressible, and stream ids live in `notification_subscriptions` rows that a per-Team id would leave behind every time a Team archived. One recipient query serves all four streams, because §6.2's two populations are the same set written twice — "active members with a `user_id` plus active grants" *is* "everyone with resolved forum access". Mutes are subtracted in SQL; there is no function that returns an unfiltered set. ## Four deviations from the design of record Each is in the as-built header on Part 6 rather than a rewrite, so the original reasoning stays legible. 1. **There was no web notification settings screen to add the Team list to.** §6.3 says the mute list goes "under the existing notification settings screen". None existed — `/auth/me/notifications/*` was built for the app in M7 and had **zero** web consumers. Survivable while push was the only sink; not survivable for email, whose whole argument is the web-only user. So the sink and `/account/notifications` shipped together. 2. **Email defaults to `off`, not `digest`** (org lead): digest-by-default would start mailing every member of every Team the moment an operator connects Gmail. Push stays opt-out. The two sinks default opposite ways and the asymmetry lives only in the schema. 3. **Roster events do not email.** All four streams tickle; only the two forum streams reach the mail sink. 4. **A ninth member joined `MODULE_API_VERSION` 1.6.0** — `pageUrlTemplate`. Phase 3 left core with no Team page and so no way to *link* to one; an email could name a Team and not take you to it. In place rather than 1.7.0, under the phase-2 rule (a contract owes a bump only once it has landed on `main`). ## Two build decisions worth reading **The digest computes at send time and keeps no queue.** Only `last_digest_at`. A deployment down for two days sends one correct digest, not a replay; a post a moderator hid is 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 — that one would have been a security bug. `since` is clamped to seven days, and the stamp is written **only on a successful send**. **Unsubscribe is a stateless HMAC, not a token table.** Every property that makes a reset token a row is absent. Its whole capability is setting `muted` for one (user, Team) pair. Two URLs come out of one token — the body's page and the `List-Unsubscribe` header's endpoint — because RFC 8058 one-click POSTs without rendering anything. **GET on the API path redirects and does not act**, or a mail-client link scanner would mute Teams nobody asked to leave. It answers `200` whatever the token was: distinguishing a forgery would be an oracle for which (user, Team) pairs exist. ## Defects found while building - **`Number(null)` is `0`, and `0` is an integer** — a null in an id list survived `filter(Number.isInteger)` and rode into an `IN` clause as user id 0. Harmless today, which is why it would never have been noticed. Fixed in all three filters, caught by a test. - **`recipientIds: db.recipientIds` captured the function object at require time**, so the layer below could never be substituted — the model was not the seam it claimed to be. Wrapped to resolve at call time. - **`PUT` did not actually replace the set** — it wrote the entries given and left the rest standing, so `{"teams": []}` cleared nothing, which is the one thing the mandatory-empty-array rule exists for. **Found on the live rig**; invisible to unit tests and to the screen, both of which send every row. Now resets omitted Teams to defaults without touching `last_digest_at`. ## Verification - `npm test` — **1066 server**, **248 client**, both green. `npm run swagger` and `npm run routes:manifest -- --check` committed and clean. - **Walked against the live rig** (real MariaDB, real Team with 25 members, a real forum grant): schema applied at boot; the auth gate; `GET`/`PUT` prefs; an entry naming a Team the caller is not in dropped; the empty set clearing; unsubscribe POST muting, GET redirecting without acting, and a forged token indistinguishable; and the **real recipient SQL** returning the right sets — a mute honoured, a departed member and a revoked guest excluded, an unlinked member contributing no row, and a grant-only guest with no game account correctly included. - ⚠️ **Not verified: the two rendered surfaces.** The browser tooling failed in this environment (broken screenshots, 0×0 viewport, stale page text after navigation), so `/account/notifications` and the guild-page mute toggle were confirmed only by their presence in the built bundles and by their endpoints answering. The core-content-inside-a-module's-page seam is exactly what unit tests cannot see — worth a look before merge, as phase 4's panel was. --- AI-assisted: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 6 commits 2026-08-18 23:02:55 +00:00
Phase 6's foundation: the fan-out shape the existing pipeline could not express.

`pushDispatch.publish` answers "everyone subscribed to a stream" and "this one
owner". Team notifications need "these N users", because Team scoping cannot live
in a stream id: the catalog is a static registration validated at boot against a
namespaced pattern, so a stream per Team is unexpressible, and stream ids are
stored in `notification_subscriptions` rows that would need collecting every time
a Team archived. So there are FOUR fixed core streams and the Team lives entirely
in the recipient set.

`team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks
default opposite ways, and the asymmetry lives in the column defaults so no
condition anywhere has to remember it.

One recipient query serves all four streams, because §6.2's two populations are
the same set written twice: "active members with a user_id plus active grants" IS
"everyone with resolved forum access". Mutes are subtracted in SQL rather than by
the caller — there is no function here that returns an unfiltered set.

Co-Authored-By: Claude <noreply@anthropic.com>
A web-only user on a deployment running neither the Android app nor Discord gets
no notification that someone replied to their own thread — which is most users on
most deployments, and a forum where replies are invisible is a forum nobody
returns to. Email is a third consumer of the recipient set the previous commit
builds, not a fourth pipeline.

Unlike a push tickle, an email carries content: a mailbox is a destination the
recipient chose, not an untrusted relay reached by an unguessable topic. It
carries a title and an excerpt, never a full post.

The digest COMPUTES AT SEND TIME and keeps no pending-items queue. The only state
is `last_digest_at`. Three properties fall out, and the third is why it was chosen:
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.

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

One-click unsubscribe is a stateless HMAC rather than a token table. Every
property that makes a password-reset token a row is absent: 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. Its whole capability is setting `muted` for one
(user, Team) pair.

Co-Authored-By: Claude <noreply@anthropic.com>
The roster sync tickles at most ONCE per stream per run, not once per member: a
tickle is content-free, so five people joining in one sweep is five identical
notifications and one piece of information. Suppressed on a Team's FIRST roster,
the same condition the activity feed uses and the half where it matters more —
importing a 155-member guild would otherwise wake every one of their phones.

Forum notifications fire from the CONTROLLER, not from the forum model. That file
takes an already-resolved access decision and reads no membership table by design;
the fan-out reads both to compute its recipients, so calling it from inside would
make the forum model transitively depend on exactly what its header says it must
not touch. The model returns a `notify` key the controller destructures out before
the response, so the API's answer to "did my post save" is unchanged.

`pageUrlTemplate` joins the team provider — the one thing phase 6 found that the
design of record had not anticipated. Phase 3 left core with no Team page and
therefore no way to LINK to one, so a notification email could name a Team and not
take you to it. It is data rather than a callback: a function would put a module
hook on the mail path to produce a string that never varies. Relative paths only,
and protocol-relative is refused with absolute.

The unsubscribe endpoint is the only write in the public tier and the only route
with no `siteMode` — the reader is in their mail client, and the mail went out
before the site went into maintenance. POST always answers 200, valid token or
forged: distinguishing them would be an oracle for which (user, Team) pairs exist.
GET redirects and acts on nothing, so a mail client's link scanner cannot mute
Teams nobody asked to leave.

Co-Authored-By: Claude <noreply@anthropic.com>
This is phase 6's first finding, and it changed the phase's shape.

TEAMS.md §6.3 says the per-Team mute list is surfaced "under the existing
notification settings screen". There was no such screen. `/auth/me/notifications/*`
was built for the Android app in M7 and had ZERO web consumers — a browser could
not see the stream catalog or its own subscriptions at all. That is tolerable
while push is the only sink, because push needs the app anyway. It is not
tolerable for email, whose entire argument is the web-only user who runs neither
the app nor Discord, so the sink and the screen to configure it had to ship
together.

`/account/notifications` carries all three: what to be told about, which Teams,
and whether any of it reaches a mailbox — in the order a user actually reasons
about them.

The mute toggle goes in a THIRD module-declared slot, above the roster, because
muting is an action ON the guild page while the feed and forum are content IN it.
It renders nothing for a viewer with no preference available, which is a privacy
property rather than a tidiness one: whether a preference EXISTS for a Team
answers "is this person in it", and the guild page is public.

`/unsubscribe/:token` is public and POSTs on mount — the link the user clicked was
a GET, and a GET that mutated would be triggered by every mail-client link scanner.

Co-Authored-By: Claude <noreply@anthropic.com>
A notification feature is mostly things that correctly do NOT happen, and each of
these is invisible until it goes wrong in production: a departed member and a
revoked guest are not recipients; a mute subtracts per Team and leaves the user's
other Teams alone; the author of a post never receives the notification about it;
forums switched off silences the forum streams including the digest; a Team's
first roster wakes nobody; a failed send does not stamp `last_digest_at`.

Two real defects came out of writing them.

`Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived
`filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id
0, so it was harmless — which is exactly why it would never have been noticed.
Fixed in all three places that filter ids.

`recipientIds: db.recipientIds` in the model captured the function OBJECT at
require time, so the layer below could never be substituted. That is not only
untestable; it means the model was not really the seam it claimed to be. Wrapped
so `db.x` resolves at call time.

The registries catalog assertion is now an exact five-element list, so a
shard-content stream creeping back into core's registration fails here rather
than shipping.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(teams): make "replace the whole set" actually replace it
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 8m53s
13312d7fc3
Found walking the live rig, which is the only place it could be found: every unit
test and the settings screen itself send every row, so the bug was invisible to
both.

`PUT /auth/me/notifications/teams` documents itself as replacing the whole set. It
did not — it wrote the entries it was given and left every other preference
standing. So `{"teams": []}` cleared nothing, which is precisely the body the route
requires the array for: the field is mandatory even when empty so that clearing
everything is expressible, and it was the one thing that did not work.

A Team the caller could have named and did not now returns to its defaults. RESET
rather than deleted, and the difference is `last_digest_at`: that column is the
digest worker's state and not a preference, so dropping the row with it would make
every visit to the settings screen re-open a day-wide digest window and mail
somebody a summary they had already read.

Walked again after the fix on the real database: the empty set clears, an entry
naming a Team the caller is not in is still dropped, and the digest stamp survives.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit b1d3b87cd6 into edge 2026-08-18 23:10:22 +00:00
whitlocktech deleted branch feature/teams-phase6-notifications 2026-08-18 23:10:23 +00:00
Sign in to join this conversation.
No description provided.