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

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>
This commit is contained in:
2026-08-17 20:58:30 -05:00
parent d4aa5ade12
commit dda0e32dd3
11 changed files with 257 additions and 80 deletions

View File

@@ -177,6 +177,31 @@ async function getGuilds(req, res) {
}
}
// GET /public/shard/guilds/:id — one guild and its roster.
//
// The board endpoint above returns every guild WITHOUT its roster; this is the
// detail view, and it is the page that hosts core's Team activity feed through
// the `uo.guild.detail` slot (docs/website/TEAMS.md Part 3).
//
// Projected through the same `guilds` feature as the board, so an operator who
// gates guilds to staff gates this too, and `acct`/`webId` on the roster rows
// never survive below admin — those are LOCKED fields, and a roster is where they
// actually appear in bulk.
async function getGuild(req, res) {
try {
const guilds = await shardState.listGuilds()
const guild = guilds.find((g) => String(g.id) === String(req.params.id))
// 404 rather than an empty object: a guild that disbanded is gone, and the
// page needs to say so rather than render an empty shell.
if (!guild) return res.status(404).json({ message: 'Not Found' })
const members = await shardState.listGuildMembers(guild.id)
return res.json(await visibility.project('guilds', { ...guild, roster: members }, req))
} catch (err) {
log.error('shard.getGuild', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/governors — the current town-governor board (empty on shards
// without City Loyalty). Live via city.update on the public SSE stream. Projected
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
@@ -421,6 +446,7 @@ module.exports = {
getIdoc,
getChamps,
getGuilds,
getGuild,
getGovernors,
getGovernorHistory,
getPresence,

View File

@@ -100,6 +100,17 @@ shardRouter.get(
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
shard.getGuilds,
)
shardRouter.get(
'/guilds/:id',
requireFeature('guilds'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'One guild and its roster'
// #swagger.description = 'The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The guild id.' }
/* #swagger.responses[200] = { description: 'The guild, with its roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'No such guild', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
shard.getGuild,
)
shardRouter.get(
'/governors',
requireFeature('governors'),