feat(teams): the activity feed, the roster projection, and the inverted slot #152
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/teams-phase3-pages-activity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Teams phase 3, core's half. Pairs with Module-uo#11 and docs#156.
What this ships
The Teams contract, and nothing user-facing. Core owns
team_activity, the reconciler's ownitems, the retention prune, the roster's audience projection and the whole API. It renders no Team
page and registers no Team nav row — see the correction below.
team_activityandctx.teams.activity.push, which stops throwing and starts working. Twowriters, one table: core writes membership and rename items with
source='core', a module pushesgame items.
summaryis already-rendered text and core never composes one.core.forum.threadhas nothing to emit it until phase 4.GET /public/teams/:slug/activity— paged, visibility resolved from the session and never froma parameter.
projectRoster, the eighth member of MODULE_API 1.6.0 (amended in place per the org lead).optionalAuth, a new middleware.GET /public/teams/by-external/:moduleId/:externalId, so a module can name a Team its own way.The correction, mid-PR
The first four commits built four core Team pages and three nav rows, following §3.1 and §3.5. The
org lead corrected it: Teams is the platform primitive the API contract exposes; the module builds
the pages on it. module-uo builds guilds, the Rust module will build clans. Core does not own the
word for a Team.
The fifth commit removes all of that and inverts the extension-slot direction:
registry.declareModuleSlot(id, name)lets a module declare a place on its own page for coreto fill. Core fills
uo.guild.detailwith the activity feed — the one part of that page core cannothand over, since only core can resolve whether the viewer is inside the Team.
It is left as an add-then-remove rather than rewritten history because the reasoning is worth
reading; the net diff is what ships.
Five things the build disproved (all recorded as TEAMS.md amendments)
memberKey/userId; §3.2 saysneither is ever published. A client slot only receives what the browser was sent, so §3.4 meant
publishing both to every visitor. §3.2 wins.
consulted are opposite situations. Only the second fails closed — otherwise bare core serves an
empty roster on every page.
Testing
npm test(server): 931 tests.session.test.jsandbotScore.test.jsare pre-existing timingflakes — both pass in isolation.
npm test(client): 224 tests, including the inverted slot's ordering and no-op rules.routes.guards.jsonnamesoptionalAuthon the twopublic routes that now read identity.
Live-rig acceptance
Full rig per the org lead: real ServUO (two synthesized guilds from the real world), real Rust
sidecar, website with module-uo installed. Proved:
guildsatstaffgave an anonymous caller 0 rows and an admin25, same URL;
knew them by;
ctx.teams.activity.pushthrough the real ctx, and the members-only item withheld fromanonymous and from an admin who is not a member, and served to a genuine linked member;
One finding worth flagging: module-uo's
core.jsfacade has noteamsmember and the module hasnever called
ctx.teams.*at all —publish/reconcileshipped in phase 2 unused. The probe neededa temporary passthrough. Not a defect in this PR, but
ctx.teamsis unexercised from a real module.TEAMS.md Part 4. `team_activity` takes items from two sources and treats them identically on the read path: core writes its own membership and rename items with source='core', and a module pushes game items through `ctx.teams.activity.push`, which stops throwing and starts working. Core writing here too is deliberate — the rendering path is exercised by core's own content from day one, so the feed is never empty on a deployment whose module pushes nothing. Three rules shape the model: - core never composes a summary. It arrives already rendered and is stored verbatim; core cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know. - visibility fails closed. An item with no stated visibility is `members`. - a push never throws at its call site. It is called from inside a game-event handler, and a storage problem of core's must not become the module's control flow. Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing to emit it until the forum lands in phase 4 — and emits none of them for a Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155 people joining, and a join per member would bury every real event under the import and reach the row cap on day one. Retention ships with the feed rather than after someone notices. A nightly worker applies an age horizon and a per-Team row cap, both settings; either alone has a hole, since age lets one busy guild write a million rows inside the window and a cap keeps a dead Team's feed forever. The sync now reads member ROWS rather than keys, replacing the `memberKeys` call rather than adding to it: the feed needs each changing member's display name and prior `is_leader`, and the upsert is about to overwrite both. Co-Authored-By: Claude <noreply@anthropic.com>TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full roster and the player portal's "My Teams" — plus the two extension slots a module adds to them, and the nav rows that lead there. These are CORE routes, not module ones. A Team is a core platform entity that a module merely populates, so the whole experience renders on bare core; a module adds to these pages rather than supplying them. `team.member.row` is declared with `{ displayName, isLeader, linked }` and not §3.4's `{ memberKey, userId, displayName }`. The two documents contradict each other and §3.2 is the one that is a security rule: a slot component runs in the browser, so those props can only reach it by publishing a game-internal identifier and a site account id in every public roster response, for every visitor, module installed or not. Recorded as an amendment. The presentation logic is split into lib/teams.js with its own tests, following lib/teamAdmin.js, because these pages have to state differences that read as bugs unless they are worded deliberately: - "37 members · 21 linked" — the gap is information (a character with no site account behind it), and the header says what each number IS rather than showing both and hoping; - an empty roster has three unrelated causes — nobody in the Team, a rung that shows nobody, and a module that could not be asked — and reporting the last as the first is a statement about the game that happens to be false; - a stale projection says how old it is rather than presenting itself as current. `teams` is the first CORE nav row to carry a `feature` since the shard rows left with the module cutover, and it brings core's own feature provider back with it. It gates on whether this deployment has Teams AT ALL, not on who is looking — Team pages are public and the server gates them. It fails open, so an unknown answer shows the link: a Teams link leading somewhere empty is a far cheaper mistake than a Team page nobody can find. Co-Authored-By: Claude <noreply@anthropic.com>Org lead's correction, and it changes what this phase ships. TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They should never have been core's. **Teams is the platform primitive that the API contract exposes; the module builds the pages on top of it.** module-uo builds guilds; the Rust module that comes next builds clans. Core does not own the word for a Team, so a core page under a noun core invented would have sat beside module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary. Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public and portal nav rows, the `teams` feature flag and the core feature provider that answered it. /admin/teams stays — an operator inspecting the primitive is looking at the primitive. Kept, and unchanged: the tables, the reconciler, the access resolver, the activity feed, the retention prune, the whole public/player/admin API, optionalAuth and the roster projection. That is the contract, and it is what this phase was actually for. **So the extension slots invert, which is a new direction in MODULE_API §3.7.** `team.overview` and `team.member.row` assumed core rendered the page. In their place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on its own page and core fill it. Core fills `uo.guild.detail` with the Team activity feed — the one part of that page core cannot hand over, because only core can resolve whether the viewer is inside the Team and the public/members split is a security boundary. Three things about the inverted direction are load-bearing: - the name is namespaced under the declaring module and that is enforced, not conventional: it is the only thing keeping two modules off one name; - core's fills are applied at MOUNT rather than eagerly. Core's bundle evaluates before every module chunk, so when core registers a fill the slot does not exist yet — filling eagerly would silently do nothing; - a fill for a slot nobody declared is a no-op, never an error. The declaring module is simply not installed, which is the ordinary case. That is the opposite of §3.7, where an unknown slot throws, and the asymmetry is real: there, core declares first, so an unknown name is always a typo. `Slot` becomes the eighth member of the shared UI kit, so a module renders the place with core's own error boundary. It matters more here than anywhere else in the kit: the thing being contained is core's content failing inside the module's page. `GET /public/teams/by-external/:moduleId/:externalId` is added because a module names a Team in its own vocabulary and core keys the feed by slug. The module id is matched rather than trusted — an external id is unique only within a module. Co-Authored-By: Claude <noreply@anthropic.com>