docs(teams): Team pages and the activity feed, and what phase 3 disproved
TEAMS.md gains a dated amendment on phase 3 with five corrections, all found by
building the thing it describes:
- §3.2 and §3.4 contradict each other about `team.member.row`'s props, and
§3.2 wins because it is the security rule. A client slot can only receive
what the browser was sent, so §3.4's `{ memberKey, userId, displayName }`
means publishing both identifiers in every public roster, module installed
or not. The slot is redeclared with what core can honestly supply.
- §3.3's projection is an EIGHTH MODULE_API member where 1.6.0 listed seven.
Settled by the org lead: 1.6.0 is amended in place, on the rule Protocol 4
was given in phase 2 — a contract owes a bump only once it has reached
`main`.
- "the module declines" needed splitting in two before it could be built. No
module at all withholds nothing and must serve the roster whole; a module
whose rungs could not be consulted must serve none of it. Only the second
fails closed, or bare core shows an empty roster on every Team page.
- the module answers with member KEYS, not rows, so it can narrow what is
published and cannot widen it.
- core's five activity kinds are four until the forum lands, and a Team's
FIRST roster emits no join items at all.
§2.11's route table gains the activity endpoint it never had, and MODULE_API.md
documents `projectRoster`, the inverted fail-closed semantics that make it
different from every other provider call, and `ctx.teams.activity.push`'s item
shape and its four contractual properties.
BACKEND_DESIGN.md: the seventh Team table, its retention, and the three public
routes' new behaviour — `enabled` on the index, the slot props on the single
Team, the per-caller row projection on the roster, and the feed.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -525,7 +525,7 @@ 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)
|
||||
### The seven Team tables — core's, populated by a module (Teams phases 2–3)
|
||||
|
||||
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
|
||||
@@ -541,6 +541,18 @@ core's.
|
||||
| `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 |
|
||||
| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core |
|
||||
|
||||
**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth
|
||||
failure, so retention ships with the feed rather than after someone notices: a nightly worker applies
|
||||
an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap
|
||||
(`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy
|
||||
guild write a million rows inside the window, and a cap keeps a dead Team's feed forever.
|
||||
|
||||
`dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence
|
||||
trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core
|
||||
deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing
|
||||
a 155-member guild is one Team arriving, not 155 people joining.
|
||||
|
||||
**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
|
||||
@@ -829,9 +841,10 @@ 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 |
|
||||
| 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, plus `enabled` — whether this deployment has Teams at all, which the client's `teams` nav flag resolves from |
|
||||
| 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` for the `team.overview` extension slot — this route only, since the index has no slot to feed |
|
||||
| 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 |
|
||||
| — | `/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).
|
||||
|
||||
@@ -33,17 +33,23 @@ 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.** Seven additions, no removals and no changed signature, so minor;
|
||||
**1.6.0 — Teams, the whole surface.** Eight 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 provider's optional `projectRoster` · `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.
|
||||
> **Amended 2026-08-17 (phase 3), on the org lead's decision: 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`. The same
|
||||
> amendment marks `ctx.teams.activity.push` and both client slots live, and narrows
|
||||
> `team.member.row`'s props (TEAMS.md §3.4).
|
||||
|
||||
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
|
||||
are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
|
||||
that will implement it — chosen over leaving it 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 it yet; do not treat its 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
|
||||
@@ -201,13 +207,36 @@ module-uo does not need is on the list.
|
||||
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
|
||||
| `ctx.teams.publish` | `(event) => Promise<void>` | `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<void>` — **throws until the Team activity feed lands** | — | (1.6.0, declared) |
|
||||
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
|
||||
|
||||
**`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.
|
||||
|
||||
**`ctx.teams.activity.push(items)`** writes the per-Team feed (TEAMS.md §4.1). Each item is:
|
||||
|
||||
```js
|
||||
{ externalId, kind, summary, occurredAt?, visibility?, actorMemberKey?, actorUserId?, payload?, dedupeKey? }
|
||||
```
|
||||
|
||||
Four things about it are contract rather than implementation:
|
||||
|
||||
- **`summary` is already rendered and core stores it verbatim.** Core cannot phrase "gained 15,000
|
||||
gold" for a game whose vocabulary it does not know, and a core that templated it would have
|
||||
re-acquired exactly the semantics the module system exists to remove. `kind` and `payload` are
|
||||
likewise opaque — core filters on them and only the module's `team.overview` slot renders anything
|
||||
richer than the text.
|
||||
- **A Team is named by the module's own `externalId`**, which core maps, and only that module's
|
||||
ACTIVE Teams resolve. There is no id a module can send that reaches another module's Team, and an
|
||||
archived Team is not writable — its feed is a closed record of what happened before the rename.
|
||||
- **`visibility` defaults to `'members'` — fail closed.** The module chooses it per item; core
|
||||
enforces it on the read path.
|
||||
- **It never throws at the call site and never rejects.** This is called from inside a game-event
|
||||
handler, and a storage problem of core's must not become the module's control flow. A malformed
|
||||
item is dropped and logged; a `dedupeKey` collision is a successful no-op, which is what makes a
|
||||
sidecar reconnect backfill safe to replay.
|
||||
|
||||
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
|
||||
@@ -364,14 +393,16 @@ 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
|
||||
collision, reported against the module that holds it. 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.
|
||||
which is not the same as a call that fails. The fourth is optional; see below.
|
||||
|
||||
```js
|
||||
getTeams() // () => Promise<{ ok, complete?, teams }>
|
||||
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]
|
||||
|
||||
// authoritative
|
||||
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
|
||||
@@ -393,6 +424,29 @@ 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.
|
||||
|
||||
**`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It
|
||||
answers *who is allowed to look at a roster*, on the request path, because the audience model and its
|
||||
configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question,
|
||||
"keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to
|
||||
whoever asked. So this one call **fails closed**.
|
||||
|
||||
Core distinguishes two refusals, and a module does not have to do anything to get the right one:
|
||||
|
||||
- **no provider, or a provider without `projectRoster`** — there is no audience model to consult and
|
||||
nothing is being withheld, so core serves the roster whole at its own public shape. This is what
|
||||
makes the member genuinely optional: bare core, and a module with no rungs of its own, both render
|
||||
the page core writes.
|
||||
- **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core
|
||||
serves an empty roster and says so in the response (`projected: false`,
|
||||
`projectionUnavailable: true`).
|
||||
|
||||
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
|
||||
published row looks like and re-normalises whatever comes back through its own public shape, so a
|
||||
module can narrow which rows appear and cannot widen which fields do: the member key and the site
|
||||
account id are withheld from every public roster whatever a module returns.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -717,7 +717,10 @@ place it bites is uploads, where "forget me" has to mean the bytes go too, not j
|
||||
```
|
||||
GET /api/v1/public/teams list (active), paged, {stale,lastSyncAt}
|
||||
GET /api/v1/public/teams/:slug overview + counts
|
||||
GET /api/v1/public/teams/:slug/members roster, field-projected per audience rung (§3.3)
|
||||
GET /api/v1/public/teams/:slug/members roster, row-projected per audience rung (§3.3)
|
||||
GET /api/v1/public/teams/:slug/activity the feed, paged, filtered to what the caller may
|
||||
see (§4.3) — added in phase 3; a session is
|
||||
optional on this route and on /members
|
||||
|
||||
GET /api/v1/player/teams the caller's Teams (membership + grants), with the
|
||||
reason for each: 'membership' | 'grant' | both
|
||||
@@ -815,7 +818,14 @@ named for a *place* and never for a meaning):
|
||||
| Slot | Rendered in | Props |
|
||||
| --- | --- | --- |
|
||||
| `team.overview` | the Team overview page, below the counts | `{ teamId, externalId, moduleId }` |
|
||||
| `team.member.row` | each roster row, trailing cell | `{ memberKey, userId, displayName }` |
|
||||
| `team.member.row` | each roster row, trailing cell | `{ displayName, isLeader, linked }` |
|
||||
|
||||
> **Amended 2026-08-17 (phase 3).** `team.member.row` was specified with
|
||||
> `{ memberKey, userId, displayName }`, which §3.2 forbids: a slot component runs in the browser, so
|
||||
> those props can only reach it by being published in the roster response to every visitor. The two
|
||||
> identifiers are dropped. `team.overview`'s three are kept — a core row id, a game-side group id and
|
||||
> a module name name no person — and they are served on `GET /teams/:slug` only, not on the index,
|
||||
> which has no slot to feed.
|
||||
|
||||
Both unfilled on bare core, which renders exactly the page core writes. Neither is typed by content —
|
||||
`team.overview` is "the spot under the counts", not "where the game puts guild stats".
|
||||
@@ -2059,6 +2069,50 @@ guild called "Admin" cannot put an official-looking page on the site.
|
||||
|
||||
### Phase 3 — Team pages, roster, nav, activity feed (`website` + `module-uo`)
|
||||
|
||||
> **Amended 2026-08-17, while building this.** Five corrections, all found by building or testing the
|
||||
> thing described below.
|
||||
>
|
||||
> **§3.2 and §3.4 contradict each other, and §3.2 wins.** §3.2 says a member key is a game-internal
|
||||
> identifier and a user id names a site account, and that **neither is published**; §3.4 then declares
|
||||
> the `team.member.row` slot with props `{ memberKey, userId, displayName }`. A client-side slot can
|
||||
> only receive what the browser was sent, so honouring §3.4 means putting both identifiers into every
|
||||
> public roster response — for every visitor, module installed or not. **Settled: the slot is declared
|
||||
> with `{ displayName, isLeader, linked }`.** module-uo leaves it unfilled, because the useful thing to
|
||||
> put there is a link to the character behind a row and these props do not identify one; filling it
|
||||
> with a guess from a display name is worse than an empty cell. A future phase that wants this back
|
||||
> needs an opaque per-response row token, not the raw key.
|
||||
>
|
||||
> **§3.3's projection is an EIGHTH `MODULE_API` member and 1.6.0's list said seven.** Settled by the
|
||||
> org lead: **1.6.0 is amended in place** rather than bumped, applying the same rule Protocol 4 got in
|
||||
> phase 2 — a contract owes a bump only once it has landed on `main`, and 1.6.0 is on `edge` only.
|
||||
>
|
||||
> **"The module declines" needed splitting in two before it could be implemented.** §3.3 says a module
|
||||
> that declines yields the public projection, fail closed. But *no module at all* and *a module whose
|
||||
> rung system could not be consulted* are opposite situations: the first is withholding nothing and
|
||||
> must serve the roster whole, the second must serve none of it. The refusal therefore carries
|
||||
> `projects` — `false` for "there is no audience model here", `true` for "there is one and I could not
|
||||
> ask it" — and only the second fails closed. Without that split, bare core serves an empty roster on
|
||||
> every Team page.
|
||||
>
|
||||
> **The module answers with member KEYS, not rows.** §3.3's "the module returns the rows it permits"
|
||||
> would let a module widen what is published by handing back a `userId` core had withheld, and core's
|
||||
> field guarantee would then rest on every module's good behaviour. Core asks which rows, keeps what a
|
||||
> row looks like, and re-normalises whatever comes back through its own public shape.
|
||||
>
|
||||
> **Core's five activity kinds are four here.** `core.forum.thread` has nothing to emit it until the
|
||||
> forum lands in phase 4. Separately, and not in the doc at all: **the first roster for a Team emits
|
||||
> no join items.** Importing a 155-member guild is one Team arriving, not 155 people joining, and
|
||||
> emitting a join per member would bury every real event under the import and reach the row cap on day
|
||||
> one. `roster_synced_at IS NULL` is the condition, which covers both a new Team and a newly installed
|
||||
> module adopting an existing one.
|
||||
>
|
||||
> **§2.11's route table has no activity endpoint** though §4.3 describes a feed filtered by the
|
||||
> viewer's access. Added on the org lead's decision: `GET /api/v1/public/teams/:slug/activity`, paged,
|
||||
> with the visibility resolved from the session and never from a parameter. It is the first public
|
||||
> route whose *content* depends on identity, which needed a new `optionalAuth` middleware —
|
||||
> `attachSession` only decodes a token, so a banned or logged-out account would have kept reading the
|
||||
> members-only half until its JWT expired.
|
||||
|
||||
`/teams`, `/teams/:slug`, `/teams/:slug/roster`, `/player/teams`; the linked/unlinked/guest surface;
|
||||
`team.overview` + `team.member.row` slots; nav registration; `team_activity` +
|
||||
`ctx.teams.activity.push` + core's own five activity kinds + the retention prune.
|
||||
|
||||
Reference in New Issue
Block a user