feat(teams): the activity feed, the roster projection, and the inverted slot #152
@@ -79,6 +79,44 @@ async function requireAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
|
||||||
|
//
|
||||||
|
// For a PUBLIC route whose content — not merely its presentation — depends on who
|
||||||
|
// is asking. The Team activity feed is the first: `public` items go to everyone
|
||||||
|
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
|
||||||
|
// an anonymous caller must be served, not rejected, and an authenticated one must
|
||||||
|
// be identified properly.
|
||||||
|
//
|
||||||
|
// "Properly" is why this is not attachSession. That one decodes the token and
|
||||||
|
// stops, which is right for reading back your own session but wrong here: a
|
||||||
|
// banned account, a password change, or a logout would all keep working against
|
||||||
|
// the private half of the feed until the JWT expired. This runs the same
|
||||||
|
// database re-validation requireAuth does — status, cutoff, revocation — and on
|
||||||
|
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
|
||||||
|
// no longer good sees the public feed, which is exactly what they are entitled to.
|
||||||
|
//
|
||||||
|
// A database error also degrades to anonymous. On a public route the safe
|
||||||
|
// direction is to serve less, and 500ing a page because a session lookup failed
|
||||||
|
// would take the whole Team page down for callers who never sent a token.
|
||||||
|
async function optionalAuth(req, res, next) {
|
||||||
|
const session = sessionService.validateSession(req)
|
||||||
|
if (!session) return next()
|
||||||
|
try {
|
||||||
|
const user = await users.getById(session.userId)
|
||||||
|
if (!user) return next()
|
||||||
|
if (user.status && user.status !== 'active') return next()
|
||||||
|
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
|
||||||
|
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
|
||||||
|
|
||||||
|
req.user = user
|
||||||
|
req.session = session
|
||||||
|
req.authMethod = session.authMethod
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('optionalAuth: continuing anonymously', { message: err.message })
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
|
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
|
||||||
// first so req.user is populated. Use for admin-only endpoints (users, site
|
// first so req.user is populated. Use for admin-only endpoints (users, site
|
||||||
// mode, settings) so a lower-privilege editor cannot reach them.
|
// mode, settings) so a lower-privilege editor cannot reach them.
|
||||||
@@ -91,6 +129,7 @@ function requireRole(...roles) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
attachSession,
|
attachSession,
|
||||||
|
optionalAuth,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireRole,
|
requireRole,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,10 +163,66 @@ function normaliseLeaders(answer) {
|
|||||||
return { ok: true, leaders }
|
return { ok: true, leaders }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer.
|
||||||
|
*
|
||||||
|
* Deliberately a set of keys rather than a set of rows. Core already holds the
|
||||||
|
* rows and knows their public shape; asking the module for rows back would let a
|
||||||
|
* module widen what is published — re-adding a `userId` or a `memberKey` that
|
||||||
|
* §3.2 says is never published — and core's field guarantee would then rest on
|
||||||
|
* every module's good behaviour rather than on core. So the module answers the
|
||||||
|
* question it actually owns (who may be seen at this rung) and core keeps the
|
||||||
|
* question it owns (what a member row looks like in public).
|
||||||
|
*/
|
||||||
|
function normaliseVisibleKeys(answer) {
|
||||||
|
if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array')
|
||||||
|
const keys = []
|
||||||
|
for (const raw of answer.members) {
|
||||||
|
const key = str(raw)
|
||||||
|
if (!key) return fail('a projectRoster() entry is not a member key')
|
||||||
|
if (!keys.includes(key)) keys.push(key)
|
||||||
|
}
|
||||||
|
return { ok: true, members: keys }
|
||||||
|
}
|
||||||
|
|
||||||
const getTeams = () => call('getTeams', normaliseTeams)
|
const getTeams = () => call('getTeams', normaliseTeams)
|
||||||
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
|
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
|
||||||
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
|
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the module which roster rows this viewer may see (§3.3).
|
||||||
|
*
|
||||||
|
* The per-audience projection is the module's because the visibility framework
|
||||||
|
* and its rung configuration are module-owned (§10.5) — core does not know what a
|
||||||
|
* rung is. Core supplies the roster and a description of the viewer; the module
|
||||||
|
* returns the member keys it permits.
|
||||||
|
*
|
||||||
|
* **"No audience model" and "could not answer" are different, and the caller must
|
||||||
|
* be able to tell them apart** — so the refusal carries `projects`.
|
||||||
|
*
|
||||||
|
* `projects: false` — no provider is registered, or the registered one does not
|
||||||
|
* implement `projectRoster`. There is no rung system to consult and nothing
|
||||||
|
* is being withheld; the roster is served at core's public shape. This is why
|
||||||
|
* the member is OPTIONAL: bare core, and a module with no audience model of
|
||||||
|
* its own, both render exactly the page core writes.
|
||||||
|
*
|
||||||
|
* `projects: true` — the module HAS an audience model and core could not reach
|
||||||
|
* it (refused, threw, timed out, answered malformed). Here the caller must
|
||||||
|
* fail CLOSED, because "leave it alone" would mean publishing the very rows
|
||||||
|
* the rungs exist to withhold. This is the one place in the Team subsystem
|
||||||
|
* where unavailability is not staleness: everywhere else a refused call
|
||||||
|
* leaves data alone, and doing that to a *visibility* question is a leak.
|
||||||
|
*/
|
||||||
|
async function projectRoster(externalId, members, viewer) {
|
||||||
|
const provider = registries.registeredTeamProvider()
|
||||||
|
if (!provider) return { ...fail('no team provider is registered'), projects: false }
|
||||||
|
if (typeof provider.projectRoster !== 'function') {
|
||||||
|
return { ...fail('provider does not project rosters'), projects: false }
|
||||||
|
}
|
||||||
|
const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer)
|
||||||
|
return answer.ok ? answer : { ...answer, projects: true }
|
||||||
|
}
|
||||||
|
|
||||||
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
|
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
|
||||||
const providerModuleId = () => {
|
const providerModuleId = () => {
|
||||||
const provider = registries.registeredTeamProvider()
|
const provider = registries.registeredTeamProvider()
|
||||||
@@ -177,6 +233,7 @@ module.exports = {
|
|||||||
getTeams,
|
getTeams,
|
||||||
getTeamMembers,
|
getTeamMembers,
|
||||||
getTeamLeaders,
|
getTeamLeaders,
|
||||||
|
projectRoster,
|
||||||
providerModuleId,
|
providerModuleId,
|
||||||
CALL_TIMEOUT_MS,
|
CALL_TIMEOUT_MS,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,16 @@ async function listPublic({ limit = 50, offset = 0 } = {}) {
|
|||||||
teams: visible.slice(offset, offset + limit).map(publicTeam),
|
teams: visible.slice(offset, offset + limit).map(publicTeam),
|
||||||
total: visible.length,
|
total: visible.length,
|
||||||
...sync,
|
...sync,
|
||||||
|
// What the `teams` nav feature flag resolves from (§3.5). True if a provider
|
||||||
|
// is registered OR any Team exists — the second half matters because Team
|
||||||
|
// rows outlive the module that filled them, and hiding the nav entry the
|
||||||
|
// moment a module is uninstalled would make every existing Team page
|
||||||
|
// unreachable from the site while still answering by URL.
|
||||||
|
//
|
||||||
|
// False only when there is nothing and no prospect of anything, which is
|
||||||
|
// exactly the bare-core case the flag exists for: a link to a permanently
|
||||||
|
// empty page is worse than no link.
|
||||||
|
enabled: Boolean(sync.configured) || visible.length > 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +184,19 @@ async function getPublic(slug) {
|
|||||||
const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null
|
const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null
|
||||||
return {
|
return {
|
||||||
...publicTeam(row),
|
...publicTeam(row),
|
||||||
|
// The three props the `team.overview` extension slot is declared with
|
||||||
|
// (§3.4). A module's slot component runs in the browser and has to know
|
||||||
|
// WHICH Team it is looking at, in its own vocabulary — `slug` is core's name
|
||||||
|
// for it and resolves nothing on the module's side.
|
||||||
|
//
|
||||||
|
// On this route only, deliberately: the index has no slot and would
|
||||||
|
// otherwise publish a module-internal identifier per row for nothing. None
|
||||||
|
// of the three names a person — they are a core row id, a game-side group
|
||||||
|
// id and a module name, and the identifiers §3.2 withholds (member keys,
|
||||||
|
// site account ids) are not among them.
|
||||||
|
id: row.id,
|
||||||
|
externalId: row.external_id,
|
||||||
|
moduleId: row.module_id,
|
||||||
...sync,
|
...sync,
|
||||||
successor: successor && !successor.hidden
|
successor: successor && !successor.hidden
|
||||||
? { slug: successor.slug, name: successor.display_name_override || successor.name }
|
? { slug: successor.slug, name: successor.display_name_override || successor.name }
|
||||||
@@ -181,14 +204,50 @@ async function getPublic(slug) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rosterPublic(slug) {
|
/**
|
||||||
|
* A Team's roster, projected for the caller's audience rung (§3.3).
|
||||||
|
*
|
||||||
|
* The ROW filter is the module's: it owns the visibility framework and its
|
||||||
|
* configuration (§10.5), and core does not know what a rung is. The FIELD shape
|
||||||
|
* stays core's — every row that survives goes through `publicMember`, which
|
||||||
|
* withholds the member key and the user id whatever the module answers. So a
|
||||||
|
* module can narrow what is published and cannot widen it, and core's "neither is
|
||||||
|
* published" guarantee does not rest on every module's good behaviour.
|
||||||
|
*
|
||||||
|
* **A module that HAS a rung system and cannot answer withholds the roster.** That
|
||||||
|
* is the one Team call where a refusal is not staleness: leaving a visibility
|
||||||
|
* answer "alone" would publish the very rows the rungs exist to withhold. A
|
||||||
|
* deployment with no module, or one whose module does not project at all, is a
|
||||||
|
* different case entirely — nothing is being withheld there, so the roster is
|
||||||
|
* served whole at core's public shape (`projects: false`).
|
||||||
|
*/
|
||||||
|
async function rosterPublic(slug, viewer = null) {
|
||||||
const row = await teamsDb.findBySlug(slug)
|
const row = await teamsDb.findBySlug(slug)
|
||||||
if (!row || row.hidden) return null
|
if (!row || row.hidden) return null
|
||||||
const [members, sync] = await Promise.all([
|
const [members, sync] = await Promise.all([
|
||||||
access.rosterWithOverrides(row.id),
|
access.rosterWithOverrides(row.id),
|
||||||
syncStatus(),
|
syncStatus(),
|
||||||
])
|
])
|
||||||
return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at }
|
|
||||||
|
// The module gets the rows as it supplied them — this is its own data coming
|
||||||
|
// home — plus who is asking, which is all a rung decision needs.
|
||||||
|
const answer = await teamProvider.projectRoster(row.external_id, members, viewer)
|
||||||
|
let visible
|
||||||
|
if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key))
|
||||||
|
else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask
|
||||||
|
else visible = members // nothing to fail closed ABOUT
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: visible.map(publicMember),
|
||||||
|
...sync,
|
||||||
|
rosterSyncedAt: row.roster_synced_at,
|
||||||
|
// Stated rather than implied. An empty roster has three quite different
|
||||||
|
// causes — a Team with no members, a rung that shows none, and a module that
|
||||||
|
// could not be asked — and a page that cannot tell them apart will report the
|
||||||
|
// last one as the first.
|
||||||
|
projected: answer.ok,
|
||||||
|
...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Player ─────────────────────────────────────────────────────────────────
|
// ── Player ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -243,11 +243,22 @@ function checkLegShape(entry) {
|
|||||||
return { leg, label: label || leg, dispatch, classify }
|
return { leg, label: label || leg, dispatch, classify }
|
||||||
}
|
}
|
||||||
|
|
||||||
// All three methods are REQUIRED, with no optional half. A provider that could
|
// Three methods are REQUIRED, with no optional half. A provider that could list
|
||||||
// list Teams but not their members would leave core holding Teams it can never
|
// Teams but not their members would leave core holding Teams it can never
|
||||||
// populate, and the reconciler has no sensible behaviour for that — it is not the
|
// populate, and the reconciler has no sensible behaviour for that — it is not the
|
||||||
// same as a call that fails, which is staleness and already handled (§2.4). A
|
// same as a call that fails, which is staleness and already handled (§2.4). A
|
||||||
// module unable to answer one of the three answers `{ ok: false }` at call time.
|
// module unable to answer one of the three answers `{ ok: false }` at call time.
|
||||||
|
//
|
||||||
|
// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an
|
||||||
|
// audience model, and a module with no rung system of its own has no opinion to
|
||||||
|
// express. Omitting it means core serves rosters at its own public shape;
|
||||||
|
// implementing it means core fails CLOSED when the call cannot be made, so this
|
||||||
|
// is a member to add deliberately rather than by habit.
|
||||||
|
//
|
||||||
|
// The copy is explicit rather than a spread: this object is what core calls, so
|
||||||
|
// anything not named here is not part of the contract and must not survive
|
||||||
|
// registration. A method that silently rode along would look implemented from the
|
||||||
|
// module's side and be invisible from core's.
|
||||||
function checkTeamProviderShape(entry) {
|
function checkTeamProviderShape(entry) {
|
||||||
const provider = entry || {}
|
const provider = entry || {}
|
||||||
const out = {}
|
const out = {}
|
||||||
@@ -257,6 +268,12 @@ function checkTeamProviderShape(entry) {
|
|||||||
}
|
}
|
||||||
out[name] = provider[name]
|
out[name] = provider[name]
|
||||||
}
|
}
|
||||||
|
if (provider.projectRoster !== undefined) {
|
||||||
|
if (typeof provider.projectRoster !== 'function') {
|
||||||
|
throw new Error('registerTeamProvider: projectRoster must be a function if present')
|
||||||
|
}
|
||||||
|
out.projectRoster = provider.projectRoster
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// marked stale, because that is what the projection is for.
|
// marked stale, because that is what the projection is for.
|
||||||
|
|
||||||
const teams = require('../../../model/teams/teams.model')
|
const teams = require('../../../model/teams/teams.model')
|
||||||
|
const teamActivity = require('../../../model/teams/teamActivity.model')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('teams')
|
const log = require('../../../utils/logger')('teams')
|
||||||
|
|
||||||
@@ -35,9 +36,18 @@ async function getTeam(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roster, projected for whoever is asking (§3.3).
|
||||||
|
*
|
||||||
|
* The viewer is described to the module rather than handed over: it gets the
|
||||||
|
* caller's id and role, which is what a rung decision turns on, and not the user
|
||||||
|
* row — a module has `ctx.users.getById` if it needs more, and passing the whole
|
||||||
|
* record here would make every column of `users` part of this contract.
|
||||||
|
*/
|
||||||
async function getRoster(req, res) {
|
async function getRoster(req, res) {
|
||||||
try {
|
try {
|
||||||
const roster = await teams.rosterPublic(req.params.slug)
|
const viewer = req.user ? { userId: req.user.id, role: req.user.role } : null
|
||||||
|
const roster = await teams.rosterPublic(req.params.slug, viewer)
|
||||||
if (!roster) return res.status(404).json({ message: 'Team not found' })
|
if (!roster) return res.status(404).json({ message: 'Team not found' })
|
||||||
return res.json(roster)
|
return res.json(roster)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -45,4 +55,28 @@ async function getRoster(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listTeams, getTeam, getRoster }
|
/**
|
||||||
|
* A Team's activity feed (§4.3).
|
||||||
|
*
|
||||||
|
* The only handler in this tier that reads `req.user`, and it reads nothing else
|
||||||
|
* from the caller about what they may see: `limit` and `offset` are page
|
||||||
|
* controls, and the visibility filter is resolved from the session alone. A
|
||||||
|
* request parameter naming its own visibility is the bug the ENUM exists to
|
||||||
|
* prevent, so there is deliberately no way to ask for one.
|
||||||
|
*
|
||||||
|
* The cap is 100 rather than the index's 200 — every row carries a summary and an
|
||||||
|
* opaque payload, so a page of these is much larger than a page of Teams.
|
||||||
|
*/
|
||||||
|
async function getActivity(req, res) {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 50, 1), 100)
|
||||||
|
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
|
||||||
|
const feed = await teamActivity.feedFor(req.params.slug, req.user ? req.user.id : null, { limit, offset })
|
||||||
|
if (!feed) return res.status(404).json({ message: 'Team not found' })
|
||||||
|
return res.json(feed)
|
||||||
|
} catch (err) {
|
||||||
|
return fail(res, err, 'activity')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listTeams, getTeam, getRoster, getActivity }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const express = require('express')
|
|||||||
|
|
||||||
const ctrl = require('./teams.controller')
|
const ctrl = require('./teams.controller')
|
||||||
const siteMode = require('../../../middleware/siteMode')
|
const siteMode = require('../../../middleware/siteMode')
|
||||||
|
const { optionalAuth } = require('../../../auth/session.middleware')
|
||||||
|
|
||||||
const teamsRouter = express.Router()
|
const teamsRouter = express.Router()
|
||||||
|
|
||||||
@@ -43,12 +44,34 @@ teamsRouter.get(
|
|||||||
'/:slug/members',
|
'/:slug/members',
|
||||||
// #swagger.tags = ['Public · Teams']
|
// #swagger.tags = ['Public · Teams']
|
||||||
// #swagger.summary = 'Get a Team roster'
|
// #swagger.summary = 'Get a Team roster'
|
||||||
// #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.'
|
// #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.'
|
||||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||||
|
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */
|
/* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */
|
||||||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
siteMode,
|
siteMode,
|
||||||
|
optionalAuth,
|
||||||
ctrl.getRoster,
|
ctrl.getRoster,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The one route in this tier that reads the caller's identity. `optionalAuth`
|
||||||
|
// serves anonymous callers rather than rejecting them, and identifies an
|
||||||
|
// authenticated one properly enough that a banned or logged-out account drops
|
||||||
|
// back to the public half of the feed at once (TEAMS.md §4.3).
|
||||||
|
teamsRouter.get(
|
||||||
|
'/:slug/activity',
|
||||||
|
// #swagger.tags = ['Public · Teams']
|
||||||
|
// #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see'
|
||||||
|
// #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.'
|
||||||
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' }
|
||||||
|
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||||
|
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
siteMode,
|
||||||
|
optionalAuth,
|
||||||
|
ctrl.getActivity,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = teamsRouter
|
module.exports = teamsRouter
|
||||||
|
|||||||
@@ -979,6 +979,12 @@ const doc = {
|
|||||||
properties: {
|
properties: {
|
||||||
teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } },
|
teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } },
|
||||||
total: { type: 'integer', example: 12 },
|
total: { type: 'integer', example: 12 },
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page.',
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PublicTeamMember: {
|
PublicTeamMember: {
|
||||||
@@ -999,6 +1005,41 @@ const doc = {
|
|||||||
properties: {
|
properties: {
|
||||||
members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } },
|
members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } },
|
||||||
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
|
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||||
|
projected: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at core’s public shape — never the full one.',
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicTeamActivityItem: {
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the module’s `team.overview` slot renders anything richer than the text.',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', example: 4821 },
|
||||||
|
source: { type: 'string', description: '`core` or a module id.', example: 'uo' },
|
||||||
|
kind: { type: 'string', example: 'uo.champion.completed' },
|
||||||
|
summary: { type: 'string', example: 'Completed Champion Neira' },
|
||||||
|
visibility: { type: 'string', enum: ['public', 'members'] },
|
||||||
|
occurredAt: { type: 'string', format: 'date-time' },
|
||||||
|
payload: { type: 'object', nullable: true, additionalProperties: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PublicTeamActivity: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } },
|
||||||
|
total: { type: 'integer', description: 'Matching rows for THIS caller’s visibility, so paging is honest.', example: 137 },
|
||||||
|
limit: { type: 'integer', example: 50 },
|
||||||
|
offset: { type: 'integer', example: 0 },
|
||||||
|
scope: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['public', 'members'],
|
||||||
|
description:
|
||||||
|
'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PlayerTeamList: {
|
PlayerTeamList: {
|
||||||
|
|||||||
@@ -287,3 +287,90 @@ test('a hung call does not hold the process open until its deadline', async () =
|
|||||||
test('the budget is the documented ten seconds', () => {
|
test('the budget is the documented ten seconds', () => {
|
||||||
assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000)
|
assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── projectRoster: the optional fourth member (§3.3) ───────────────────────
|
||||||
|
//
|
||||||
|
// The one Team call where a refusal must NOT be treated as staleness. Every test
|
||||||
|
// below exists because the obvious implementation — reuse `call()` and serve the
|
||||||
|
// roster when it fails — silently publishes the rows the rungs exist to withhold.
|
||||||
|
|
||||||
|
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
|
||||||
|
|
||||||
|
test('projectRoster is optional: a provider without it registers fine', () => {
|
||||||
|
const api = registries.stage('uo')
|
||||||
|
assert.doesNotThrow(() => api.registerTeamProvider(ok()))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-function projectRoster is rejected at registration, not at call time', () => {
|
||||||
|
const api = registries.stage('uo')
|
||||||
|
assert.throws(
|
||||||
|
() => api.registerTeamProvider({ ...ok(), projectRoster: 'yes please' }),
|
||||||
|
/projectRoster must be a function/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unregistered method cannot ride along into the provider core calls', () => {
|
||||||
|
register('uo', { ...ok(), somethingElse: async () => 'hi' })
|
||||||
|
assert.equal(registries.registeredTeamProvider().somethingElse, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no provider at all is projects:false — nothing is being withheld', async () => {
|
||||||
|
const answer = await teamProvider.projectRoster('g1', rows, null)
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.projects, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a provider that does not project is projects:false, not a failure to fear', async () => {
|
||||||
|
register('uo', ok())
|
||||||
|
const answer = await teamProvider.projectRoster('g1', rows, null)
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.projects, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a provider that HAS projectRoster and refuses is projects:true — the caller must fail closed', async () => {
|
||||||
|
register('uo', { ...ok(), projectRoster: async () => ({ ok: false, reason: 'atlas not loaded' }) })
|
||||||
|
const answer = await teamProvider.projectRoster('g1', rows, null)
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.projects, true)
|
||||||
|
assert.equal(answer.reason, 'atlas not loaded')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a projectRoster that throws is projects:true as well — a bug is not permission', async () => {
|
||||||
|
register('uo', { ...ok(), projectRoster: async () => { throw new Error('boom') } })
|
||||||
|
const answer = await teamProvider.projectRoster('g1', rows, null)
|
||||||
|
assert.equal(answer.projects, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the module receives the rows and the viewer, and answers with member keys', async () => {
|
||||||
|
let seen
|
||||||
|
register('uo', {
|
||||||
|
...ok(),
|
||||||
|
projectRoster: async (externalId, members, viewer) => {
|
||||||
|
seen = { externalId, members, viewer }
|
||||||
|
return { ok: true, members: ['0x2'] }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const answer = await teamProvider.projectRoster('g1', rows, { userId: 7, role: 'player' })
|
||||||
|
assert.deepEqual(seen.members, rows)
|
||||||
|
assert.deepEqual(seen.viewer, { userId: 7, role: 'player' })
|
||||||
|
assert.equal(seen.externalId, 'g1')
|
||||||
|
assert.deepEqual(answer.members, ['0x2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a malformed key list is a refusal, so the caller fails closed rather than serving garbage', async () => {
|
||||||
|
for (const bad of [{ ok: true }, { ok: true, members: ['ok', ''] }, { ok: true, members: 'all' }]) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
register('uo', { ...ok(), projectRoster: async () => bad })
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const answer = await teamProvider.projectRoster('g1', rows, null)
|
||||||
|
assert.equal(answer.ok, false, JSON.stringify(bad))
|
||||||
|
assert.equal(answer.projects, true)
|
||||||
|
registries._reset()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicate keys are collapsed', async () => {
|
||||||
|
register('uo', { ...ok(), projectRoster: async () => ({ ok: true, members: ['0x1', '0x1', '0x2'] }) })
|
||||||
|
const answer = await teamProvider.projectRoster('g1', rows, null)
|
||||||
|
assert.deepEqual(answer.members, ['0x1', '0x2'])
|
||||||
|
})
|
||||||
|
|||||||
122
server/test/teamRoster.test.js
Normal file
122
server/test/teamRoster.test.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// The roster read and its audience projection (docs/website/TEAMS.md §3.2, §3.3).
|
||||||
|
//
|
||||||
|
// Two questions meet here and the file exists to keep them apart:
|
||||||
|
//
|
||||||
|
// WHICH ROWS is the module's — it owns the visibility framework and its rung
|
||||||
|
// configuration, and core does not know what a rung is.
|
||||||
|
// WHAT A ROW is core's — the member key and the user id are never published,
|
||||||
|
// LOOKS LIKE whatever the module answers.
|
||||||
|
//
|
||||||
|
// The dangerous simplification is to let the module return rows instead of keys:
|
||||||
|
// core's field guarantee would then rest on every module's good behaviour rather
|
||||||
|
// than on core, and one module re-adding a `userId` would publish site accounts
|
||||||
|
// against in-game characters on a public page.
|
||||||
|
const { test, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const teamsDb = require('../src/model/teams/teams.db')
|
||||||
|
const access = require('../src/model/teams/teamAccess.model')
|
||||||
|
const teamProvider = require('../src/model/teams/teamProvider')
|
||||||
|
const teamSync = require('../src/model/teams/teamSync.model')
|
||||||
|
const teams = require('../src/model/teams/teams.model')
|
||||||
|
|
||||||
|
const saved = new Map()
|
||||||
|
|
||||||
|
function patch(mod, name, fn) {
|
||||||
|
if (!saved.has(mod)) saved.set(mod, new Map())
|
||||||
|
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
|
||||||
|
mod[name] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
|
||||||
|
saved.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
{ member_key: '0x1', display_name: 'Aldric', user_id: 7, is_leader: 1, rank_label: 'Leader', online: 1 },
|
||||||
|
{ member_key: '0x2', display_name: 'Brenna', user_id: null, is_leader: 0, rank_label: null, online: 0 },
|
||||||
|
{ member_key: '0x3', display_name: 'Cadfael', user_id: 9, is_leader: 0, rank_label: null, online: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
patch(teamsDb, 'findBySlug', async (slug) =>
|
||||||
|
(slug === 'the-guild'
|
||||||
|
? { id: 1, external_id: 'g1', slug, hidden: 0, status: 'active', roster_synced_at: null }
|
||||||
|
: undefined))
|
||||||
|
patch(access, 'rosterWithOverrides', async () => ROWS.map((r) => ({ ...r })))
|
||||||
|
// syncStatus() reads sync state and the poll interval; neither is what this
|
||||||
|
// file is about, and both would otherwise reach the pool.
|
||||||
|
patch(teamProvider, 'providerModuleId', () => null)
|
||||||
|
patch(teamSync, 'intervalSeconds', async () => 900)
|
||||||
|
})
|
||||||
|
afterEach(restore)
|
||||||
|
|
||||||
|
test('with no module projecting, the whole roster is served at core\'s public shape', async () => {
|
||||||
|
patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: false, reason: 'no provider' }))
|
||||||
|
const roster = await teams.rosterPublic('the-guild', null)
|
||||||
|
assert.equal(roster.members.length, 3)
|
||||||
|
assert.equal(roster.projected, false)
|
||||||
|
assert.equal(roster.projectionUnavailable, undefined, 'nothing was withheld, so nothing to report')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the module chooses which rows a viewer sees', async () => {
|
||||||
|
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x2'] }))
|
||||||
|
const roster = await teams.rosterPublic('the-guild', null)
|
||||||
|
assert.deepEqual(roster.members.map((m) => m.displayName), ['Brenna'])
|
||||||
|
assert.equal(roster.projected, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a module that projects but cannot answer withholds the roster — it does not serve it', async () => {
|
||||||
|
// The whole point. "Leave it alone" is right for a roster SYNC and wrong for a
|
||||||
|
// visibility question: it would publish exactly what the rungs withhold.
|
||||||
|
patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: true, reason: 'sidecar down' }))
|
||||||
|
const roster = await teams.rosterPublic('the-guild', null)
|
||||||
|
assert.deepEqual(roster.members, [])
|
||||||
|
assert.equal(roster.projected, false)
|
||||||
|
assert.equal(roster.projectionUnavailable, true, 'an empty roster must be distinguishable from a silent one')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the module cannot widen the published fields, only narrow the rows', async () => {
|
||||||
|
// A module answering with keys it was given still yields core's shape. There is
|
||||||
|
// no answer it can give that puts a member key or a user id on a public page.
|
||||||
|
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0x2', '0x3'] }))
|
||||||
|
const roster = await teams.rosterPublic('the-guild', null)
|
||||||
|
for (const member of roster.members) {
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(member).sort(),
|
||||||
|
['displayName', 'isLeader', 'linked', 'online', 'rankLabel'],
|
||||||
|
'the public member shape is core\'s and is closed',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert.deepEqual(roster.members.map((m) => m.linked), [true, false, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a key the module invents matches nothing rather than adding a row', async () => {
|
||||||
|
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0xNOPE'] }))
|
||||||
|
const roster = await teams.rosterPublic('the-guild', null)
|
||||||
|
assert.equal(roster.members.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the viewer is described to the module, not handed over', async () => {
|
||||||
|
let seen
|
||||||
|
patch(teamProvider, 'projectRoster', async (externalId, members, viewer) => {
|
||||||
|
seen = viewer
|
||||||
|
return { ok: true, members: members.map((m) => m.member_key) }
|
||||||
|
})
|
||||||
|
await teams.rosterPublic('the-guild', { userId: 7, role: 'player' })
|
||||||
|
assert.deepEqual(seen, { userId: 7, role: 'player' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown slug is not found, and the module is never consulted about it', async () => {
|
||||||
|
let called = false
|
||||||
|
patch(teamProvider, 'projectRoster', async () => { called = true; return { ok: true, members: [] } })
|
||||||
|
assert.equal(await teams.rosterPublic('no-such-team', null), null)
|
||||||
|
assert.equal(called, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a hidden team\'s roster does not answer publicly at all', async () => {
|
||||||
|
patch(teamsDb, 'findBySlug', async () => ({ id: 1, external_id: 'g1', slug: 'x', hidden: 1, status: 'active' }))
|
||||||
|
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1'] }))
|
||||||
|
assert.equal(await teams.rosterPublic('x', null), null)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user