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 the bet owes before `edge` becomes
`main` (org lead, 2026-08-18).
One sentence in it is already wrong rather than merely incomplete.
`book/02-website-module.md` tells a reader that core declares a slot and a module
may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares
three — a new game's module cannot implement Teams at all without the inverted
direction.
Two shapes are genuinely new and worth teaching: the inverted slot, and
`registerTeamProvider` as the first registration where core calls the module and
waits — with the asymmetry that every call fails stale except `projectRoster`,
which fails closed, because for a visibility question "keep what you have" means
serving the roster unprojected.
The phase explicitly does NOT enumerate the contract. The kit already teaches four
members and has never mentioned notification streams, announce legs or post hooks,
all of which predate Teams. MODULE_API.md is normative; the kit teaches one path
and links out.
Its ordering is awkward and is stated rather than smoothed over: it is written
before the cutover and can only merge after it, because CI clones the pinned sha
and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does
not reach `main` until the cutover lands.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
TEAMS.md §5.4, §5.5.7 (new), §5.6 and the Part 12 phase entry; BACKEND_DESIGN.md's
schema and admin route tables.
**The largest change is a decision, not a description.** §5.6's first rule said
"a leader may also see and act on reports for their own Team, but staff always
receive them". The org lead settled on 2026-08-18 that the leader half is
**decided against, not deferred**: the gap the section exists to close is that
leaders moderate their own forum and a Team's leaders are exactly the people who
will not report their own Team, so a leader-visible queue hands a complaint about
a leader back to them — and a read-only leader view still tells them who reported
what. Recorded as an amendment rather than by editing the sentence away, because
the reasoning for the original is what makes the correction legible.
**§5.6's `content_reports` DDL does not work as written, and the amendment says so
rather than quietly swapping it.** With `status` in the unique key, CLOSED rows
collide with each other: dismiss a report, let the behaviour recur, dismiss the
second one, and the UPDATE lands on a tuple that already exists — so the queue
starts throwing duplicate-key errors on the first repeat reporter. The shipped
table keys on a generated `open_marker`, the same encoding
`team_forum_grants.active_marker` uses. Three smaller departures are recorded
beside it: `handled_note`, the two username snapshots §2.10 asks for everywhere
else, and a real CASCADE on `team_id`.
**New §5.5.7 for `teams_forum_edit_window_minutes`** (0–1440, default 15), and the
rule under it: the window is resolved on the server TWICE — the read path stamps
`canEdit`/`editableUntil` so a client knows whether to draw the control, the write
re-derives it from `created_at` before allowing anything. The read is advice and
the write is enforcement, because a time-bounded permission must not take its
clock from the party it bounds. That is also why the key is not published: the
client needing the number is the admin screen, and the client needing the decision
already has it per post.
**§5.4 gains three notes its route table does not carry**: thread creation splits
authority by TYPE rather than widening the leader gate (and reports it as two
booleans, since one would make a client guess which right it described); post
moderation is its own route whose validator accepts all eight actions so the model
can say "pin applies to a thread, not to a post"; and a reply's three refusal codes
are chosen to be distinguishable — 404 absent, 400 announcement, 409 locked — with
locked refusing staff too.
The Part 12 entry records what the phase disproved, its four acceptance criteria,
that it spans ONE repo where phase 4 needed two, and the single defect the live rig
found. It also notes that phase 4 shipped `uploads` with the default off, so §5.6's
"pull reports forward if uploads is enabled anywhere" never triggered.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:29:56 -05:00
3 changed files with 379 additions and 21 deletions
@@ -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
[`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
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_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`,
`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
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/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/*`
@@ -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/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).
@@ -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 |
| 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 |
| 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/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) |
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
`npm run swagger` regenerated and committed · `npm run routes:manifest -- --check` zero-line diff ·
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.