diff --git a/client/src/api.js b/client/src/api.js
index 28aed0d..2746aab 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -39,6 +39,7 @@ export const shard = {
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
+ guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
diff --git a/client/src/core.js b/client/src/core.js
index bce4793..f3da217 100644
--- a/client/src/core.js
+++ b/client/src/core.js
@@ -48,7 +48,7 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
)
}
-// The curated kit (§3.4). Seven members, closed: anything else this module needs
+// The curated kit (§3.4). Eight members, closed: anything else this module needs
// it bundles itself, which is why `components/` next door exists at all.
export const {
PublicLayout,
@@ -59,6 +59,11 @@ export const {
useAsync,
useAuth,
useSite,
+ // Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED
+ // direction — this module declares a place on its own page and CORE fills it.
+ // Shared rather than reimplemented so core's content failing inside our page is
+ // contained by core's own error boundary.
+ Slot,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index 3b78cf0..1c10dc5 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -26,6 +26,7 @@ import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
+import Guild from './routes/public/Guild.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
@@ -81,6 +82,7 @@ registry.registerRoutes(ID, {
{ path: 'shard/activity', element: },
{ path: 'champs', element: },
{ path: 'guilds', element: },
+ { path: 'guilds/:id', element: },
{ path: 'governors', element: },
{ path: 'houses', element: },
{ path: 'rules', element: },
@@ -180,6 +182,17 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
+// ── The inverted slot: this module DECLARES, core fills ────────────────────
+//
+// The other three above are core's slots that this module fills. This one is the
+// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
+// populates, but core does not own the word "guild" and publishes no Team page of
+// its own — so the page is ours and core contributes the activity feed to it.
+//
+// Declared under this module's own namespace, which core enforces. Core's fill is
+// applied after every module chunk has evaluated, so declaring it here is early
+// enough; on a core that knows nothing of Teams it simply stays empty.
+registry.declareModuleSlot(ID, 'uo.guild.detail')
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a
diff --git a/client/src/routes/public/Guild.jsx b/client/src/routes/public/Guild.jsx
new file mode 100644
index 0000000..91ce62c
--- /dev/null
+++ b/client/src/routes/public/Guild.jsx
@@ -0,0 +1,110 @@
+import { useParams, Link } from 'react-router-dom'
+import api from '../../api.js'
+import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
+
+// One guild: its roster, and the place core puts the Team activity feed.
+//
+// **This page is the reason the extension-slot direction inverts**
+// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this
+// module is what populates them — but core does not own the word "guild", so it
+// publishes no Team page of its own. The page is this module's; the activity feed
+// on it is core's, because only core can resolve whether the viewer is inside the
+// Team, and the public/members split on that feed is a security boundary.
+//
+// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a
+// core that does not know about Teams the slot is simply never filled and this
+// page renders its roster alone, which is the same tolerance every other slot has.
+//
+// The roster comes from this module's OWN board — the same data it answers core's
+// Team provider from — rather than from core's Team API. That is deliberate: the
+// board is the authoritative copy here, and reading core's projection of our own
+// answer back would be a round trip through a staler copy of our own data.
+
+function rankOf(m) {
+ // Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for
+ // staff, because ServUO reports GameMaster-and-above as Leader whatever their
+ // real rank — emitting that verbatim would publish every staff member in a
+ // guild as one of its leaders (docs/link/v4.md).
+ if (m.rankName) return m.rankName
+ return null
+}
+
+function MemberRow({ m }) {
+ const rank = rankOf(m)
+ const linked = m.webId != null || m.acct != null
+ return (
+
+
+
+ {/* Keyed by serial: two characters can share a display name,
+ which this shard's own world actually contains. */}
+ {roster.map((m) => )}
+
+
+
+ )}
+
+ {roster.length === 0 && (
+
No roster has been received for this guild yet.
+ )}
+
+ {/* Core's Team activity feed lands here. Nothing renders on a core
+ that does not fill it, or when there is nothing to show. The guild
+ is named in OUR terms — core maps its own Team from these two. */}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/client/src/routes/public/Guilds.jsx b/client/src/routes/public/Guilds.jsx
index b907c5d..be9ec0e 100644
--- a/client/src/routes/public/Guilds.jsx
+++ b/client/src/routes/public/Guilds.jsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
import { useShardFeed } from '../../lib/useShardFeed.js'
import api from '../../api.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
@@ -15,9 +16,12 @@ function Leader({ leader }) {
function GuildRow({ g }) {
return (
-
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
-
+
)
}
diff --git a/client/test/registration.test.js b/client/test/registration.test.js
index 24e536f..f3a124c 100644
--- a/client/test/registration.test.js
+++ b/client/test/registration.test.js
@@ -44,6 +44,7 @@ function fakeRg() {
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
+ const declaredSlots = new Set()
return {
version: '1.3.0',
react,
@@ -54,7 +55,7 @@ function fakeRg() {
// object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
- ['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
+ ['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
@@ -72,10 +73,19 @@ function fakeRg() {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
+ // The INVERTED direction (core API 1.6.0): this module declares a place on
+ // its OWN page and core fills it. Core enforces the namespace, so the fake
+ // does too — a chunk that declared an unnamespaced slot would pass here and
+ // throw in a browser.
+ declareModuleSlot(id, name) {
+ if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
+ if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
+ declaredSlots.add(name)
+ },
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
- _read: () => ({ routes, nav, providers, extensions }),
+ _read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
@@ -98,7 +108,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
it('registers routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
- assert.equal(routes.public.length, 12)
+ assert.equal(routes.public.length, 13)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
@@ -166,7 +176,7 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
-it('fills the three extension slots, each with a component', () => {
+it('fills the three CORE extension slots, each with a component', () => {
const { extensions } = registered
assert.deepEqual(
[...extensions.keys()].sort(),
@@ -198,3 +208,17 @@ it('registers under exactly one module id, matching the manifest', () => {
])
assert.deepEqual([...owners], [manifest.id])
})
+
+it('declares its own guild-detail slot, for core to fill', () => {
+ // The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
+ // core page: core owns the activity feed and this module owns the word "guild",
+ // so this module declares the place and core puts the feed in it.
+ assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail'])
+})
+
+it('the declared slot is rendered by the page that owns it', () => {
+ // A slot nothing renders is a slot core fills into the void. Asserted against
+ // the source rather than the chunk, since the chunk is minified.
+ const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
+ assert.match(page, /name="uo\.guild\.detail"/)
+})
diff --git a/routes.manifest.json b/routes.manifest.json
index a588abd..1b8d47d 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -201,6 +201,11 @@
"path": "/api/v1/public/shard/guilds",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/public/shard/guilds/:id",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/public/shard/houses",
diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js
index 41f5d2b..8b5b76b 100644
--- a/server/model/teamProvider/teamProvider.model.js
+++ b/server/model/teamProvider/teamProvider.model.js
@@ -25,6 +25,7 @@ const db = require('./teamProvider.db')
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../../utils/uoLinkSocket')
const clilocs = require('../shardClilocs/shardClilocs.model')
+const visibility = require('../../utils/shardVisibility')
const log = core.logger('teams')
@@ -251,4 +252,69 @@ function resolveUserId(row) {
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
}
-module.exports = { getTeams, getTeamMembers, getTeamLeaders, boardIsCurrent }
+/**
+ * Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0).
+ *
+ * The optional fourth provider method, and the only one core calls on a REQUEST
+ * path rather than from the reconciler. Core holds the roster and 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 (`utils/shardVisibility`)
+ * and core does not know what a rung is.
+ *
+ * **The answer is all-or-nothing, and that is correct rather than a shortcut.**
+ * A rung is a property of the FEATURE, not of a member: `guilds` is either
+ * visible to this viewer or it is not, and there is no configuration in which
+ * some members of a guild are public and others are not. Returning every key or
+ * none is the honest translation of the model this module actually has.
+ *
+ * **A refusal here costs visibility, not staleness.** Core fails closed on this
+ * one call — an unanswered visibility question serves an empty roster rather than
+ * an unprojected one — so every path below that cannot reach a confident answer
+ * refuses deliberately, and the catch does too. That is the opposite of the rule
+ * governing the other three methods, and it is the right way round: for a roster
+ * SYNC an unanswered call must change nothing, and for a roster READ it must
+ * publish nothing.
+ *
+ * Note what this does NOT do: strip fields. `acct` and `webId` are the leak this
+ * module's projection exists to prevent on the live feed, and neither is in
+ * core's roster shape at all — core withholds the member key and the site account
+ * id from every public roster whatever this returns. So there is nothing here to
+ * redact, only rows to withhold.
+ */
+async function projectRoster(externalId, members, viewer) {
+ try {
+ const config = await visibility.getConfig()
+ const feature = config.guilds
+ // An admin turned guilds off. Nobody sees a roster, including staff — the
+ // switch means "this shard does not publish guild data", not "publish it
+ // quietly".
+ if (!feature || !feature.enabled) return { ok: true, members: [] }
+
+ // `viewerLevel` reads a REQUEST; core hands over a described viewer instead,
+ // which is deliberate — it keeps the `users` row out of the contract.
+ //
+ // The no-viewer case is answered here rather than by handing `viewerLevel` an
+ // empty object: given a request with no `req.user` it falls through to
+ // `auth.getUserFromRequest`, which expects real cookies and headers and
+ // throws on a synthetic one. That throw would land in the catch below and
+ // become a REFUSAL, so every anonymous visitor would have been served an
+ // empty roster on a shard whose guilds are public. Anonymous is a known
+ // answer, not a failed lookup.
+ const level = viewer
+ ? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } })
+ : 'anonymous'
+ if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] }
+
+ return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) }
+ } catch (err) {
+ // Core reads this as "withhold the roster". Saying so is the whole point: the
+ // alternative — answering with every key because the config read failed —
+ // publishes a roster an operator may have gated to staff.
+ log.warn('projectRoster could not resolve visibility; withholding the roster', {
+ externalId, message: err.message,
+ })
+ return refuse(`visibility could not be resolved: ${err.message}`)
+ }
+}
+
+module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent }
diff --git a/server/router/public/shard.controller.js b/server/router/public/shard.controller.js
index cfa1357..10604e4 100644
--- a/server/router/public/shard.controller.js
+++ b/server/router/public/shard.controller.js
@@ -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,
diff --git a/server/router/public/shard.router.js b/server/router/public/shard.router.js
index 4997add..bcd744e 100644
--- a/server/router/public/shard.router.js
+++ b/server/router/public/shard.router.js
@@ -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'),
diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js
index 3305fb3..c5c28fc 100644
--- a/server/test/teamProvider.test.js
+++ b/server/test/teamProvider.test.js
@@ -27,6 +27,7 @@ const db = require('../model/teamProvider/teamProvider.db')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../utils/uoLinkSocket')
const clilocs = require('../model/shardClilocs/shardClilocs.model')
+const visibility = require('../utils/shardVisibility')
const provider = require('../model/teamProvider/teamProvider.model')
const saved = []
@@ -335,3 +336,71 @@ test('an empty board is an authoritative empty list — the shard really has no
assert.equal(answer.ok, true)
assert.deepEqual(answer.teams, [])
})
+
+// ── projectRoster (TEAMS.md §3.3) ──────────────────────────────────────────
+//
+// The refusal semantics INVERT here and that is the point of these tests. For
+// the three methods above, a refusal means "change nothing" and an empty array
+// would be destructive. For this one, core fails CLOSED — a refusal withholds the
+// roster — 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.
+
+const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
+
+function guilds(feature) {
+ patch(visibility, 'getConfig', async () => ({ guilds: feature }))
+}
+
+test('a viewer at or above the audience sees every row', async () => {
+ guilds({ enabled: true, audience: 'anonymous' })
+ const answer = await provider.projectRoster('1', rows, null)
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, ['0x1', '0x2'])
+})
+
+test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => {
+ // `ok: true` with an empty list is the correct answer here: this module KNOWS
+ // the viewer may see nothing. Core renders an empty roster rather than an
+ // error, which is what a gated shard is supposed to look like.
+ guilds({ enabled: true, audience: 'staff' })
+ const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' })
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, [])
+})
+
+test('an admin clears every audience', async () => {
+ guilds({ enabled: true, audience: 'admin' })
+ const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
+ assert.deepEqual(answer.members, ['0x1', '0x2'])
+})
+
+test('a disabled guilds feature hides the roster from everyone, staff included', async () => {
+ // The switch means "this shard does not publish guild data", not "publish it
+ // quietly to staff".
+ guilds({ enabled: false, audience: 'anonymous' })
+ const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, [])
+})
+
+test('an unreadable visibility config REFUSES rather than publishing', async () => {
+ // The inversion, stated. Core reads this as "withhold", which is the only safe
+ // reading of "I could not work out who is allowed to look".
+ patch(visibility, 'getConfig', async () => { throw new Error('pool down') })
+ const answer = await provider.projectRoster('1', rows, null)
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /visibility could not be resolved/)
+})
+
+test('an absent viewer is anonymous, not an error', async () => {
+ guilds({ enabled: true, audience: 'logged_in' })
+ const answer = await provider.projectRoster('1', rows, null)
+ assert.equal(answer.ok, true)
+ assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in')
+})
+
+test('rows with no member key are dropped rather than answered as blanks', async () => {
+ guilds({ enabled: true, audience: 'anonymous' })
+ const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null)
+ assert.deepEqual(answer.members, ['0x1'])
+})
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 65e2897..bda4e1d 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -3131,6 +3131,56 @@
}
}
},
+ "/api/v1/public/shard/guilds/{id}": {
+ "get": {
+ "tags": [
+ "Public · Shard"
+ ],
+ "summary": "One guild and its roster",
+ "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.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The guild id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The guild, with its roster",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "404": {
+ "description": "No such guild",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
"/api/v1/public/shard/houses": {
"get": {
"tags": [