feat(teams): the roster's audience projection, and optionalAuth to resolve it

TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per
the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a
bump only once it has landed on `main`.

Two questions meet on the roster and they belong to different owners. WHICH
ROWS a viewer may see is the module's, because the audience rungs and their
configuration live there and core does not know what a rung is. WHAT A ROW
LOOKS LIKE stays core's.

So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows
would let a module widen what is published — handing back a `userId` core had
withheld — leaving core's field guarantee resting on every module's good
behaviour. Core asks which rows and re-normalises the answer through its own
public shape, so a module can narrow and cannot widen.

"The module declines" needed splitting before it could be implemented. No
module at all and a module whose rungs could not be consulted are opposite
situations: the first withholds nothing and must serve the roster whole, the
second must serve none of it. The refusal carries `projects`, and only
`projects: true` fails closed. Without the split, bare core serves an empty
roster on every Team page.

This is also the first public route whose CONTENT depends on identity, which
needed a middleware core did not have. `attachSession` only decodes a token, so
a banned account, a password change or a logout would have kept working against
the private half of a feed until the JWT expired. `optionalAuth` runs
requireAuth's full database re-validation and, on any failure, continues
ANONYMOUSLY rather than rejecting — a caller whose session is no longer good
sees the public view, which is what they are entitled to.

`GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route
table had no activity endpoint though §4.3 describes a filtered feed. Paged,
with the visibility resolved from the session and never from a parameter.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:15:36 -05:00
parent aa332eda82
commit 03631d7d40
9 changed files with 486 additions and 7 deletions

View File

@@ -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
// first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them.
@@ -91,6 +129,7 @@ function requireRole(...roles) {
module.exports = {
attachSession,
optionalAuth,
requireAuth,
requireRole,
}

View File

@@ -163,10 +163,66 @@ function normaliseLeaders(answer) {
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 getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, 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. */
const providerModuleId = () => {
const provider = registries.registeredTeamProvider()
@@ -177,6 +233,7 @@ module.exports = {
getTeams,
getTeamMembers,
getTeamLeaders,
projectRoster,
providerModuleId,
CALL_TIMEOUT_MS,
}

View File

@@ -156,6 +156,16 @@ async function listPublic({ limit = 50, offset = 0 } = {}) {
teams: visible.slice(offset, offset + limit).map(publicTeam),
total: visible.length,
...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
return {
...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,
successor: successor && !successor.hidden
? { 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)
if (!row || row.hidden) return null
const [members, sync] = await Promise.all([
access.rosterWithOverrides(row.id),
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 ─────────────────────────────────────────────────────────────────

View File

@@ -243,11 +243,22 @@ function checkLegShape(entry) {
return { leg, label: label || leg, dispatch, classify }
}
// All three methods are REQUIRED, with no optional half. A provider that could
// list Teams but not their members would leave core holding Teams it can never
// Three methods are REQUIRED, with no optional half. A provider that could list
// 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
// 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.
//
// `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) {
const provider = entry || {}
const out = {}
@@ -257,6 +268,12 @@ function checkTeamProviderShape(entry) {
}
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
}

View File

@@ -5,6 +5,7 @@
// marked stale, because that is what the projection is for.
const teams = require('../../../model/teams/teams.model')
const teamActivity = require('../../../model/teams/teamActivity.model')
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) {
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' })
return res.json(roster)
} 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 }

View File

@@ -12,6 +12,7 @@ const express = require('express')
const ctrl = require('./teams.controller')
const siteMode = require('../../../middleware/siteMode')
const { optionalAuth } = require('../../../auth/session.middleware')
const teamsRouter = express.Router()
@@ -43,12 +44,34 @@ teamsRouter.get(
'/:slug/members',
// #swagger.tags = ['Public · Teams']
// #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 modules projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the modules 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.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
/* #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" } } } } */
siteMode,
optionalAuth,
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 Teams 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