From 680a4866acb61dc26bb815549ddedfc93a04b18c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 15:32:21 -0500 Subject: [PATCH] docs(teams): Team core, MODULE_API 1.6.0, and what phase 2 disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents phase 2 of docs/website/TEAMS.md across the three files that had to change, and records the five places building it disagreed with the design. ## MODULE_API.md — 1.6.0 The Team surface becomes contract: `api.registerTeamProvider(...)`, `ctx.teams.publish` / `ctx.teams.reconcile` / `ctx.teams.activity.push`, `api.registerSlashCommands(...)`, and the two client slots. Additions only, so minor; module-uo's `coreApi: "^1.3.0"` still resolves. Per the org lead's decision, one 1.6.0 covers the whole surface rather than a minor per phase -- so the document names the phase against each member, and the two that cannot work yet are marked as present-and-throwing rather than left to be discovered at runtime. `registerTeamProvider` gets the fullest treatment because it is the first registration where core calls the MODULE and waits for an answer. The envelope, the 10-second budget and the refusal semantics are all contract, not implementation: they are how a module says "I cannot answer" without core hearing "there is nothing". `ctx.teams` is documented as push-only, with the reason there is no reader — a module answers questions about Teams, it does not ask them. ## BACKEND_DESIGN.md The six Team tables, the rename rule, the active-only uniqueness encoding, the per-column account-deletion decisions, and all eighteen routes across the three tier tables. Two entries there exist to stop a future reader "fixing" them: why `team_forum_grants` does not use the obvious generated column, and why the two columns TEAMS.md never mentioned have to exist. ## TEAMS.md — five amendments, marked as amendments with their date - **§2.5's SQL and §2.10's decision cannot both hold.** MariaDB refuses ON DELETE SET NULL on a base column of a stored generated column (1901), so §2.5's `active_user` forces the CASCADE that §2.10 exists to prevent. §2.10 wins; the marker is re-encoded for identical semantics. - **`team_forum_grants` lands in phase 2**, so the four-path resolver is written once and its non-contamination tests are real. - **Two columns the document did not contemplate**, both serving §2.4's gates: `roster_synced_at`, because sync state is per MODULE and gate 3 leaves one Team behind while the others sync; and `members_empty_since`, gate 4's per-Team quarantine. - **`leader` on the member shape is not path 2.** Taking §2.3 and §2.5 both literally gives one column two writers, and the roster writes first — so a refused `getTeamLeaders()` silently demoted everyone. Found by its own test. - **§2.8.2's matcher needed two narrow widenings**, both real impersonation vectors the whole-word rule missed: a term matches a name word's singular ("Guild of Moderators"), and a run of single-letter words is compared joined ("G.M."). Neither re-admits substring matching. Pairs with website (Teams phase 2) and Module-uo (the provider). Refs docs/website/TEAMS.md Part 12 phase 2 Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 69 +++++++++++++++++++++++++++++ website/MODULE_API.md | 91 ++++++++++++++++++++++++++++++++++++++- website/TEAMS.md | 42 ++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ed1b5b9..ec76ec5 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -525,6 +525,55 @@ 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 six Team tables — core's, populated by a module (Teams phase 2) + +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 +here is core-internal — a module must never read or write one, even though a module is what fills +them — and none carries a `_` prefix, correctly: that rule binds modules, and these are +core's. + +| Table | What it holds | +|---|---| +| `teams` | the Team itself. `external_id` is the module's own stable id, opaque to core; `name` is **immutable** for the life of the row; `slug` is derived once at create and frozen with it | +| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive | +| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine | +| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection | +| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in this phase so the access resolver is written once; the grant flow lands with the forums | +| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings | + +**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`, +`external_id`, `name`) taken together: a known id under a new name archives the old row +(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the +old Team keeps its activity, its grants and its forum as a read-only record and its old slug still +resolves. Whether two names are "really" the same guild is the module's judgement, expressed in +whether it reuses the external id. + +**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has +no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams` +are NULL for archived rows, so any number of them may share an `external_id`. + +**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is +`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the +generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base +column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would +delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The +semantics are identical: at most one active grant per (team, user), unlimited revoked rows. + +**Account deletion is settled per column, not inherited from the defaults.** Content and audit +survive; preferences and links do not. `team_members.user_id` and every actor column on the grant +ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record +stays readable after the account is gone. Only `team_id` cascades. + +**Two columns exist that the design of record did not contemplate**, both on `teams` and both +serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per +*module* and a single Team's roster can be left untouched while the others sync — without a per-Team +stamp that Team's page would report the module's last success as its own; and `members_empty_since`, +the per-Team twin of `pending_empty_since`. + +Design of record: [`TEAMS.md`](TEAMS.md) Part 2. The contract surface a module sees is +[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it. + --- ## 4. API contract @@ -654,6 +703,14 @@ and relies on it: its `/player/shard/*` handlers are the identical self-scoped o under `/admin/shard/*`, so the two are interchangeable. This is why a staff account with linked game characters gets its "My characters" and personal notification streams on the mobile client — the group no longer 403s a non-`player` role. +`teams.router.js` joins the group in Teams phase 2, and relies on exactly that rule: a moderator is in +guilds too, and gating this group on the role would 403 them off their own Teams. + +| Method | Path | Notes | +|---|---|---| +| GET | `/teams` | the caller's Teams, each carrying the **reason** it is listed: `membership` \| `grant` \| `both`. Membership and forum access are separate authority paths and the reason is what keeps them distinguishable — `both` is a real state, and a Team **hidden** from public surfaces is still listed here, because suppression is a public-surface rule and a member is not a member of the public | +| GET | `/teams/:slug/access` | the caller's own resolved access on one Team: `allowed`, `viaMembership`, `viaGrant` (kept even when membership also holds, so the grant survives as audit history) and `isLeader` with any staff override applied | + **Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The @@ -772,6 +829,9 @@ from the per-route **siteMode** middleware (§5), never from an auth gate. | GET | `/wiki` | list of pages (slug + title) | | GET | `/wiki/:slug` | single page | | POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` | +| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current | +| 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 | +| 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. The module's per-audience field projection lands with the Team pages | | — | `/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). @@ -842,6 +902,15 @@ file a route sits in — that is the property the route manifest freezes. | POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag | | POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` | | DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted | +| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said | +| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact | +| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust | +| POST | `/teams/:id/archive` · `/teams/:id/hide` | staff archive / hide. **Not gated** — both withdraw a Team from public surfaces rather than publishing anything, and withdrawing has to be possible at once, by whoever is on duty | +| 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 | `/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) | Every admin write logs to `activity_log`. diff --git a/website/MODULE_API.md b/website/MODULE_API.md index c26d0a1..0da19c5 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,31 @@ here extends the contract first, in this file, before the module is written agai Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js -const MODULE_API_VERSION = '1.5.0' +const MODULE_API_VERSION = '1.6.0' ``` The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two 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.** Seven 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` · +`api.registerSlashCommands(...)` · the client slots `team.overview` and `team.member.row`. + +**The number covers the whole surface; the members arrive by phase, and each is marked below.** Three +are live now. `ctx.teams.activity.push` and `api.registerSlashCommands` are **present and throw**, with +an error naming the phase that will implement them — chosen over leaving them absent so that a module +written against the published version fails at registration with a sentence explaining itself, rather +than at whatever moment someone first exercises the feature. Do not call them yet; do not treat a +throw as a bug. + +**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every +existing one is either the module claiming a mount or core notifying it; the closest precedent is +`registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes +the envelope, the 10-second budget and the refusal semantics contract rather than implementation — +they are how a module says "I cannot answer" without core hearing "there is nothing". + **1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop — `'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always written by hand (§3.4). Found by the acceptance run in @@ -181,6 +199,28 @@ module-uo does not need is on the list. | `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) | | `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) | | `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks | +| `ctx.teams.publish` | `(event) => Promise` | `model/teams/teamSync` | the Team provider's module (1.6.0) | +| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) | +| `ctx.teams.activity.push` | `(items) => Promise` — **throws until the Team activity feed lands** | — | (1.6.0, declared) | + +**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers* +questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a +`getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the +module already holds, in its own store. + +Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership +change visible at once; `reconcile` is a debounced *request* that returns immediately and never +rejects. Correctness comes from reconciliation either way, so neither can make a module's own call +site slow or turn a background failure into the module's error. + +The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`, +`team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because +leadership is its own authority path: a leadership change has to be expressible without pretending +someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry +`memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not +invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one +from a delta either, because an archive driven by a message that may simply have been repeated is +destruction on no evidence. Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate: @@ -217,6 +257,8 @@ api.registerExtension(slot, router) api.registerNotificationStreams(streams) api.registerAnnounceLeg({ leg, label, dispatch, classify }) api.registerPostHook({ onSaved, onDeleted }) +api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0 +api.registerSlashCommands([...]) // 1.6.0, throws until phase 7 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` @@ -316,6 +358,53 @@ meant a `dispatch` that must not be retried and a `classify` that means nothing. Before it existed, core's post controller required `utils/newsGump` directly — core's publish path 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. + +**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 +two disjoint sets under one table with no rule for merging them. A second registration is a +collision, reported against the module that holds it. All three methods are required — a provider +that could list Teams but not their members would leave core holding Teams it can never populate, +which is not the same as a call that fails. + +```js +getTeams() // () => Promise<{ ok, complete?, teams }> +getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }> +getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey] + +// authoritative +{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] } +// the module knows it cannot answer — sidecar down, cache cold, boot not finished +{ ok: false, reason: 'sidecar unreachable' } +``` + +A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is +resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be +core reading a module's table by name. + +**Every method returns an envelope, never a bare array, and this is the load-bearing part of the +contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a +non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate +`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is +the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is +the one a module returns while its sidecar is still connecting. + +A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and +surfaces it. It never empties a roster on an answer it does not trust. + +`complete: false` means "valid but partial": core applies additions and updates and performs **no** +removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony. + +**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly +omitted from a roster is indistinguishable, downstream, from that member having left — core would +mark them departed on the strength of a broken payload. Refusing costs one interval of staleness. + +**`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it +throws with an error naming the phase that will. Present rather than absent so a module written +against the published version fails at registration with an explanation, instead of at the moment +someone first types the command. + **`onBoot(fn)` / `onShutdown(fn)`** — §2.5. ### 2.5 Lifecycle diff --git a/website/TEAMS.md b/website/TEAMS.md index dd4d6e4..18961bc 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1888,6 +1888,14 @@ this protocol. ## Part 11 — `MODULE_API_VERSION` bump proposal +> **Amended 2026-08-17, on the org lead's decision.** The seven additions below land under **one** +> 1.6.0, declared in phase 2, rather than a minor bump per phase. `MODULE_API.md` therefore documents +> members before they work, so each is marked with the phase that implements it, and the two that do +> not yet — `ctx.teams.activity.push` (§4, phase 3) and `api.registerSlashCommands` (§7.1, phase 7) — +> are **present and throw** with an error naming that phase. Present rather than absent so a module +> written against the published version fails at registration with an explanation, instead of at +> whatever moment someone first exercises the feature. + **1.6.0 — minor.** Every change is an addition; no member is removed and no existing signature changes, so `MODULE_API.md` §1.1's table gives minor, and `module-uo`'s `coreApi: "^1.3.0"` still resolves. @@ -1991,6 +1999,40 @@ line with a continuation flag for the pathological guild. ### Phase 2 — Team core (`website` + `module-uo` + `docs`) +> **Amended 2026-08-17, while building this.** Five corrections, all found by building or testing the +> thing described below. +> +> **§2.5's SQL and §2.10's decision cannot both hold as written.** §2.5 gives `team_forum_grants` a +> generated column `active_user AS (IF(revoked_at IS NULL, user_id, NULL))` and a `CASCADE` foreign +> key; §2.10 later settles that key as `SET NULL` so the audit trail survives an account deletion. +> MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column of a STORED +> generated column (error 1901), so the generated column forces the `CASCADE` — and with it, the loss +> §2.10 exists to prevent. **Settled: §2.10 wins.** The marker is derived from `revoked_at` alone and +> `user_id` moves into the unique KEY, which gives identical semantics — at most one active grant per +> (team, user), unlimited revoked rows — with `user_id` free to be `SET NULL`. +> +> **`team_forum_grants` is created in this phase**, not in phase 4, so the four-path resolver is +> written once and its non-contamination tests are real. Nothing writes it yet; the grant flow, the +> per-Team cap and the leader UI stay phase 4's. +> +> **Two columns on `teams` that this document did not contemplate**, both serving §2.4's gates. +> `roster_synced_at`: `team_sync_state` holds one row per *module*, and gate 3 leaves one Team's roster +> untouched while the others sync — without a per-Team stamp that Team's page would report the +> module's last success as its own, which is exactly the staleness the gate exists to surface. +> `members_empty_since`: gate 4's per-Team quarantine, the twin of `pending_empty_since`. +> +> **`leader` on the member shape is not path 2.** §2.3 puts `leader` on a member and §2.5 says the sync +> writes `is_leader` from `getTeamLeaders()`; taking both literally gives one column two writers, and +> the roster's write lands *first* — so a refused `getTeamLeaders()` silently demoted everyone. The +> roster now **seeds** `is_leader` on insert only, so a Team is not leaderless while that call is +> failing, and `getTeamLeaders()` alone moves it afterwards. +> +> **The §2.8.2 matcher needed two narrow widenings**, both real impersonation vectors the whole-word +> rule missed: a single-word term also matches a name word's singular ("Guild of Moderators"), and a +> run of two or more single-letter words is also compared joined ("G.M."). Neither re-admits substring +> matching — only a trailing `s` off the *whole* term is stripped, and the join is of single letters, +> never of the whole name. + `teams`, `team_members`, `team_sync_state`; `registerTeamProvider` + the three `ctx.teams` members (**`MODULE_API_VERSION` → 1.6.0**); the reconciler with all four refusal gates; the four-path resolver with its non-contamination tests; `team_leader_overrides`; the public/player/admin read API;