feat(guilds): the roster projection, a guild detail page, and the slot core fills #11

Merged
whitlocktech merged 2 commits from feat/teams-phase3-projection-slot into edge 2026-08-18 02:12:20 +00:00
Member

Teams phase 3, the module's half. Pairs with website#152 and docs#156.

Merge website#152 first — this needs registry.declareModuleSlot and Slot in the UI kit, and
CI clones core at ci/core-ref.json, so this will be red until that lands and the pin moves.

What this ships

projectRoster — the optional fourth provider method, and the only one core calls on a request
path. Core holds the roster and owns its public shape; who is allowed to look is this module's,
because the audience rungs live here and core does not know what a rung is.

The answer is all-or-nothing, which is the honest translation rather than a shortcut: a rung is a
property of the feature, and there is no configuration where some members of a guild are public and
others are not.

The refusal semantics invert here, and the tests say so. For the other three methods a refusal
means "change nothing" and an empty array would be destructive. Core fails closed on this one, so
the dangerous answer is the opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff.

/uo/guilds/:id — the detail view the board never had, with the roster from this module's own
board (the same data it answers core's provider from). It declares uo.guild.detail and core fills it
with the Team activity feed. GET /public/shard/guilds/:id backs it, gated and projected through the
same guilds feature as the board.

TeamOverviewStrip is deleted with the core Team page it filled. team.member.row is not declared:
the useful thing in a roster row is a link to the character behind it, and nothing core could supply
identifies one.

The bug the tests caught

viewerLevel takes a request; core hands over a described viewer. Given a synthetic request with no
req.user it falls through to auth.getUserFromRequest, which expects real cookies and throws
and that throw would have become a refusal, so every anonymous visitor would have been served an
empty roster on a shard whose guilds are public.
Anonymous is now answered directly, as the known
answer it is.

Testing

  • npm test --prefix server: 428 tests, including the inverted refusal semantics.
  • npm test --prefix client: 42 tests; the registration test now asserts the declared slot and
    that the page owning it actually renders it — a slot nothing renders is a slot core fills into the
    void.
  • swagger-fragment.json and routes.manifest.json regenerated.

Live-rig acceptance

Real ServUO + real sidecar + website with this module installed:

  • guilds raised to staff gave an anonymous caller 0 roster rows and an admin 25, same URL;
  • the detail endpoint served 25 rows anonymously with acct/webId stripped — a roster is where
    the locked fields appear in bulk;
  • core's activity feed rendered inside this module's guild page through the declared slot;
  • another module id cannot read this module's Team (/by-external/rust/1 → 404).

  • AI-assisted: written with Claude Code (Claude Opus 5).
Teams phase 3, the module's half. Pairs with website#152 and docs#156. **Merge website#152 first** — this needs `registry.declareModuleSlot` and `Slot` in the UI kit, and CI clones core at `ci/core-ref.json`, so this will be red until that lands and the pin moves. ## What this ships **`projectRoster`** — the optional fourth provider method, and the only one core calls on a request path. Core holds the roster and owns its public shape; who is *allowed to look* is this module's, because the audience rungs live here and core does not know what a rung is. The answer is all-or-nothing, which is the honest translation rather than a shortcut: a rung is a property of the *feature*, and there is no configuration where some members of a guild are public and others are not. **The refusal semantics invert here, and the tests say so.** For the other three methods a refusal means "change nothing" and an empty array would be destructive. Core fails *closed* on this one, so the dangerous answer is the opposite — returning every key because the config could not be read would publish a roster an operator gated to staff. **`/uo/guilds/:id`** — the detail view the board never had, with the roster from this module's own board (the same data it answers core's provider from). It declares `uo.guild.detail` and core fills it with the Team activity feed. `GET /public/shard/guilds/:id` backs it, gated and projected through the same `guilds` feature as the board. `TeamOverviewStrip` is deleted with the core Team page it filled. `team.member.row` is not declared: the useful thing in a roster row is a link to the character behind it, and nothing core could supply identifies one. ## The bug the tests caught `viewerLevel` takes a *request*; core hands over a described viewer. Given a synthetic request with no `req.user` it falls through to `auth.getUserFromRequest`, which expects real cookies and **throws** — and that throw would have become a refusal, so **every anonymous visitor would have been served an empty roster on a shard whose guilds are public.** Anonymous is now answered directly, as the known answer it is. ## Testing - `npm test --prefix server`: 428 tests, including the inverted refusal semantics. - `npm test --prefix client`: 42 tests; the registration test now asserts the declared slot *and* that the page owning it actually renders it — a slot nothing renders is a slot core fills into the void. - `swagger-fragment.json` and `routes.manifest.json` regenerated. ## Live-rig acceptance Real ServUO + real sidecar + website with this module installed: - `guilds` raised to `staff` gave an anonymous caller **0 roster rows** and an admin **25**, same URL; - the detail endpoint served 25 rows anonymously with `acct`/`webId` **stripped** — a roster is where the locked fields appear in bulk; - core's activity feed rendered inside this module's guild page through the declared slot; - another module id cannot read this module's Team (`/by-external/rust/1` → 404). --- - [x] AI-assisted: written with Claude Code (Claude Opus 5).
wtclaude added 2 commits 2026-08-18 02:05:46 +00:00
The module's half of TEAMS.md phase 3.

`projectRoster` is the optional fourth provider method and the only one core
calls on a request path. Core holds the roster and owns its public shape; the
question that is this module's is who is allowed to look, because the audience
rungs and their configuration live here.

The answer is all-or-nothing, which is the honest translation rather than a
shortcut: a rung is a property of the FEATURE, and there is no configuration in
which some members of a guild are public and others are not.

The refusal semantics INVERT here, and the tests say so. For the other three
methods a refusal means "change nothing" and an empty array would be
destructive. Core fails CLOSED on this one, so the dangerous answer is the
opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff. Every path that cannot reach a
confident answer refuses, including the catch.

The anonymous case is answered directly rather than by handing `viewerLevel` a
synthetic request. Given one with no `req.user` it falls through to
`auth.getUserFromRequest`, which expects real cookies and throws on a fake — and
that throw would have become a refusal, so every anonymous visitor would have
been served an empty roster on a shard whose guilds are public. Caught by the
tests, not by reading.

`team.overview` gets a live population reading beside core's stored one. Core's
number comes from the last roster sync and is coarse by construction; this is
the `presence.online` feed this module already holds. It is explicitly not a
per-Team presence figure — the shard publishes a global aggregate and no
per-guild breakdown exists on the wire, so claiming one would be inventing a
number — and it renders nothing at all when it has nothing true to say.

`team.member.row` is left unfilled. The useful thing to put there is a link to
the character behind a row, and the props core can supply do not identify one:
the member key and the site account id are withheld from every public roster.
An empty cell beats a guess.

Co-Authored-By: Claude <noreply@anthropic.com>
feat(guilds): a guild detail page, and the slot core puts the feed in
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Failing after 33s
PR Checks / server-tests (pull_request) Successful in 8m46s
dda0e32dd3
The module's half of the org lead's correction: Teams is the contract, guilds
are the presentation, and the presentation is this module's.

Adds `/uo/guilds/:id` — the detail view the board never had — with the roster
from this module's OWN board, which is the same data it answers core's Team
provider from. Reading core's projection of our own answer back would be a round
trip through a staler copy of it.

The page declares `uo.guild.detail` and core fills it with the Team activity
feed. That is the one part of this page core cannot hand over: only core can
resolve whether the viewer is inside the Team, and the public/members split on
that feed is a security boundary. The guild is named in OUR terms — core maps
its own Team from the module id and the external id — so this module never holds
core's row id or slug.

`TeamOverviewStrip` is deleted with the core Team page it filled.
`team.member.row` is not declared here either: the useful thing to put in a
roster row is a link to the character behind it, and nothing core could supply
identifies one.

`GET /public/shard/guilds/:id` backs the page, gated and projected through the
same `guilds` feature as the board — so an operator who raises that audience
raises this too, and the locked acct/webId fields never survive below admin. A
roster is where those appear in bulk, which makes this the endpoint where
getting the projection wrong would matter most.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit eb30e4ae37 into edge 2026-08-18 02:12:20 +00:00
whitlocktech deleted branch feat/teams-phase3-projection-slot 2026-08-18 02:12:21 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#11
No description provided.